├── server ├── Procfile ├── .gitignore ├── src │ ├── MyContext.ts │ ├── utils │ │ ├── sendRefreshToken.ts │ │ ├── auth.ts │ │ ├── validation.ts │ │ ├── messages.ts │ │ ├── createTypeOrmConnection.ts │ │ └── createRandom.ts │ ├── entity │ │ ├── Transaction.ts │ │ ├── Card.ts │ │ ├── Account.ts │ │ └── User.ts │ ├── middleware.ts │ ├── resolvers │ │ ├── CardResolver.ts │ │ ├── TransactionResolver.ts │ │ ├── UserResolver.ts │ │ └── AccountResolver.ts │ └── index.ts ├── tsconfig.json ├── ormconfig.json └── package.json ├── client ├── build │ ├── _redirects │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── static │ │ ├── css │ │ │ ├── main.3609ce2a.chunk.css │ │ │ └── main.3609ce2a.chunk.css.map │ │ ├── media │ │ │ ├── mc_symbol.543bc93b.svg │ │ │ ├── world.7aea52bb.svg │ │ │ ├── flag.36ab476e.svg │ │ │ └── uk.e5564902.svg │ │ └── js │ │ │ ├── runtime-main.844668cf.js │ │ │ ├── 2.76c6d5bb.chunk.js.LICENSE.txt │ │ │ └── runtime-main.844668cf.js.map │ ├── manifest.json │ ├── precache-manifest.1f2247de76a6c560e3bc294074f75a8b.js │ ├── service-worker.js │ ├── asset-manifest.json │ ├── Bitcoin.svg │ ├── loading.svg │ └── index.html ├── public │ ├── _redirects │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── Bitcoin.svg │ ├── loading.svg │ └── index.html ├── .env ├── src │ ├── react-app-env.d.ts │ ├── graphql │ │ ├── logout.graphql │ │ ├── createCard.graphql │ │ ├── destroyAccount.graphql │ │ ├── createAccount.graphql │ │ ├── deleteAccount.graphql │ │ ├── createTransaction.graphql │ │ ├── account.graphql │ │ ├── updatePassword.graphql │ │ ├── accounts.graphql │ │ ├── cards.graphql │ │ ├── transactions.graphql │ │ ├── me.graphql │ │ ├── addMoney.graphql │ │ ├── exchange.graphql │ │ ├── login.graphql │ │ └── register.graphql │ ├── utils │ │ ├── accessToken.ts │ │ └── theme.ts │ ├── setupTests.ts │ ├── schemas │ │ ├── addMoneyValidationSchema.ts │ │ ├── changePasswordValidationSchema.ts │ │ ├── loginValidationSchema.ts │ │ └── registerValidationSchema.ts │ ├── components │ │ ├── Loading │ │ │ ├── Loading.style.ts │ │ │ └── Loading.tsx │ │ ├── Charts │ │ │ ├── Chart.style.ts │ │ │ └── Chart.tsx │ │ ├── Backdrop │ │ │ ├── Backdrop.style.ts │ │ │ └── Backdrop.tsx │ │ ├── Cards │ │ │ ├── styles │ │ │ │ ├── AccountsCard.style.ts │ │ │ │ ├── TransactionCard.style.ts │ │ │ │ └── ApolloCard.style.ts │ │ │ ├── ApolloCard.tsx │ │ │ ├── AccountsCard.tsx │ │ │ └── TransactionCard.tsx │ │ ├── Typography │ │ │ └── Title.tsx │ │ ├── Dialog │ │ │ ├── Dialog.tsx │ │ │ └── Dialog.style.ts │ │ ├── SideDrawer │ │ │ ├── DrawerToggleButton.tsx │ │ │ ├── DrawerToggleButton.style.ts │ │ │ ├── SideDrawer.style.ts │ │ │ └── SideDrawer.tsx │ │ ├── Alerts │ │ │ └── AlertMessage.tsx │ │ ├── Forms │ │ │ └── FormTextField.tsx │ │ └── Toolbar │ │ │ ├── Toolbar.style.ts │ │ │ └── Toolbar.tsx │ ├── index.css │ ├── assets │ │ ├── mc_symbol.svg │ │ ├── world.svg │ │ ├── flag.svg │ │ └── uk.svg │ ├── pages │ │ ├── Bye.tsx │ │ ├── Login │ │ │ ├── Login.style.ts │ │ │ └── Login.tsx │ │ ├── Dashboard │ │ │ ├── styles │ │ │ │ └── Dashboard.style.ts │ │ │ └── Dashboard.tsx │ │ ├── Home.tsx │ │ ├── Register │ │ │ ├── Register.style.ts │ │ │ └── Register.tsx │ │ └── Accounts │ │ │ ├── styles │ │ │ └── Account.style.ts │ │ │ └── Transactions │ │ │ └── Transactions.tsx │ ├── App.tsx │ ├── index.tsx │ └── Routes.tsx ├── .env.production ├── .prettierrc ├── codegen.yml ├── .gitignore ├── tsconfig.json └── package.json ├── images ├── blank.png ├── first.png ├── dashboard.png └── register.png ├── LICENSE ├── README.md └── CONTRIBUTING.md /server/Procfile: -------------------------------------------------------------------------------- 1 | web: node dist/index.js -------------------------------------------------------------------------------- /client/build/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /client/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_SERVER_URL=http://localhost:4000 2 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/graphql/logout.graphql: -------------------------------------------------------------------------------- 1 | mutation Logout { 2 | logout 3 | } 4 | -------------------------------------------------------------------------------- /client/.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_SERVER_URL=https://apollobank.herokuapp.com:4000 -------------------------------------------------------------------------------- /client/src/graphql/createCard.graphql: -------------------------------------------------------------------------------- 1 | mutation createCard { 2 | createCard 3 | } 4 | -------------------------------------------------------------------------------- /images/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/images/blank.png -------------------------------------------------------------------------------- /images/first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/images/first.png -------------------------------------------------------------------------------- /images/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/images/dashboard.png -------------------------------------------------------------------------------- /images/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/images/register.png -------------------------------------------------------------------------------- /client/build/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/graphql/destroyAccount.graphql: -------------------------------------------------------------------------------- 1 | mutation DestroyAccount { 2 | destroyAccount 3 | } 4 | -------------------------------------------------------------------------------- /client/build/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/client/build/favicon.ico -------------------------------------------------------------------------------- /client/build/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/client/build/logo192.png -------------------------------------------------------------------------------- /client/build/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/client/build/logo512.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edwardcdev/apollobank/HEAD/client/public/logo512.png -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build/ 5 | tmp/ 6 | temp/ 7 | .env 8 | dist 9 | .env.prod -------------------------------------------------------------------------------- /client/src/graphql/createAccount.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateAccount($currency: String!) { 2 | createAccount(currency: $currency) 3 | } 4 | -------------------------------------------------------------------------------- /client/src/graphql/deleteAccount.graphql: -------------------------------------------------------------------------------- 1 | mutation DeleteAccount($currency: String!) { 2 | deleteAccount(currency: $currency) 3 | } 4 | -------------------------------------------------------------------------------- /client/src/graphql/createTransaction.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateTransaction($currency: String!) { 2 | createTransaction(currency: $currency) 3 | } 4 | -------------------------------------------------------------------------------- /client/src/graphql/account.graphql: -------------------------------------------------------------------------------- 1 | query Account($currency: String!) { 2 | account(currency: $currency) { 3 | id 4 | balance 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 4, 5 | "useTabs": false, 6 | "printWidth": 100, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /server/src/MyContext.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | export interface MyContext { 4 | req: Request; 5 | res: Response; 6 | payload?: { userId: string }; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/graphql/updatePassword.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdatePassword($oldPassword: String!, $newPassword: String!) { 2 | updatePassword(oldPassword: $oldPassword, newPassword: $newPassword) 3 | } 4 | -------------------------------------------------------------------------------- /client/src/graphql/accounts.graphql: -------------------------------------------------------------------------------- 1 | query Accounts { 2 | accounts { 3 | id 4 | balance 5 | currency 6 | sortCode 7 | iban 8 | bic 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/graphql/cards.graphql: -------------------------------------------------------------------------------- 1 | query Cards { 2 | cards { 3 | id 4 | cardNumber 5 | pin 6 | expiresIn 7 | cvv 8 | monthlySpendingLimit 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/src/graphql/transactions.graphql: -------------------------------------------------------------------------------- 1 | query Transactions($currency: String!) { 2 | transactions(currency: $currency) { 3 | id 4 | transactionType 5 | date 6 | amount 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/graphql/me.graphql: -------------------------------------------------------------------------------- 1 | query Me { 2 | me { 3 | id 4 | email 5 | firstName 6 | lastName 7 | dateOfBirth 8 | streetAddress 9 | postCode 10 | city 11 | country 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/graphql/addMoney.graphql: -------------------------------------------------------------------------------- 1 | mutation AddMoney($amount: Float!, $currency: String!) { 2 | addMoney(amount: $amount, currency: $currency) { 3 | account { 4 | id 5 | balance 6 | } 7 | message 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /client/src/utils/accessToken.ts: -------------------------------------------------------------------------------- 1 | export let accessToken: string = ''; 2 | 3 | export const getAccessToken = (): string => { 4 | return accessToken; 5 | }; 6 | 7 | export const setAccessToken = (token: string): void => { 8 | accessToken = token; 9 | }; 10 | -------------------------------------------------------------------------------- /server/src/utils/sendRefreshToken.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | 3 | export const sendRefreshToken = (res: Response, token: string): Response => { 4 | return res.cookie("jid", token, { 5 | httpOnly: true, 6 | path: "/refresh_token" 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /client/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/extend-expect'; 6 | -------------------------------------------------------------------------------- /client/src/schemas/addMoneyValidationSchema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | export const addMoneyValidationSchema = yup.object({ 4 | amount: yup 5 | .number() 6 | .required() 7 | .positive('Amount must be a positive number') 8 | .integer(), 9 | }); 10 | -------------------------------------------------------------------------------- /client/src/components/Loading/Loading.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | 3 | export const useLoadingStyles = createUseStyles({ 4 | root: { 5 | display: 'flex', 6 | justifyContent: 'center', 7 | }, 8 | image: { 9 | height: '124px', 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /client/build/static/css/main.3609ce2a.chunk.css: -------------------------------------------------------------------------------- 1 | body,html{height:100%}body{margin:0;font-family:"Source sans Pro",sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:snow}code{font-family:source-code-pro,Menlo,Monaco,Consolas,"Courier New",monospace} 2 | /*# sourceMappingURL=main.3609ce2a.chunk.css.map */ -------------------------------------------------------------------------------- /client/src/components/Charts/Chart.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | 3 | export const useChartStyles = createUseStyles({ 4 | root: { 5 | width: '100%', 6 | height: '320px', 7 | }, 8 | spending: { 9 | display: 'flex', 10 | justifyContent: 'center', 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/components/Backdrop/Backdrop.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | 3 | export const useBackdropStyles = createUseStyles({ 4 | backdrop: { 5 | position: 'fixed', 6 | width: '100%', 7 | height: '100%', 8 | top: 0, 9 | left: 0, 10 | background: 'rgba(0,0,0,0.3)', 11 | zIndex: 100, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /client/src/components/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useLoadingStyles } from './Loading.style'; 3 | 4 | export const Loading: React.FC = () => { 5 | const classes = useLoadingStyles(); 6 | 7 | return ( 8 |
9 | Loading... 10 |
11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | font-family: 'Source sans Pro', sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | background-color: snow; 11 | height: 100%; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/Backdrop/Backdrop.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useBackdropStyles } from './Backdrop.style'; 3 | 4 | interface BackdropProps { 5 | click(): void; 6 | } 7 | 8 | export const Backdrop: React.FC = (props: BackdropProps) => { 9 | const classes = useBackdropStyles(); 10 | 11 | return
; 12 | }; 13 | -------------------------------------------------------------------------------- /client/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 'http://localhost:4000/graphql' 3 | documents: 'src/graphql/*.graphql' 4 | generates: 5 | src/generated/graphql.tsx: 6 | plugins: 7 | - 'typescript' 8 | - 'typescript-operations' 9 | - 'typescript-react-apollo' 10 | config: 11 | withHOC: false 12 | withComponent: false 13 | withHooks: true 14 | -------------------------------------------------------------------------------- /client/src/graphql/exchange.graphql: -------------------------------------------------------------------------------- 1 | mutation Exchange($selectedAccountCurrency: String!, $toAccountCurrency: String!, $amount: Float!) { 2 | exchange( 3 | selectedAccountCurrency: $selectedAccountCurrency 4 | toAccountCurrency: $toAccountCurrency 5 | amount: $amount 6 | ) { 7 | account { 8 | id 9 | balance 10 | } 11 | message 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # # production 12 | # /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/src/graphql/login.graphql: -------------------------------------------------------------------------------- 1 | mutation Login($email: String!, $password: String!) { 2 | login(email: $email, password: $password) { 3 | accessToken 4 | user { 5 | id 6 | email 7 | firstName 8 | lastName 9 | dateOfBirth 10 | streetAddress 11 | postCode 12 | city 13 | country 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/schemas/changePasswordValidationSchema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | export const changePasswordValidationSchema = yup.object({ 4 | oldPassword: yup.string(), 5 | newPassword: yup.lazy(value => 6 | !value ? yup.string() : yup.string().min(6, 'Password must be at least 6 characters'), 7 | ), 8 | confirmPassword: yup.string().oneOf([yup.ref('newPassword')], 'Passwords do not match'), 9 | }); 10 | -------------------------------------------------------------------------------- /server/src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../entity/User"; 2 | import { sign } from "jsonwebtoken"; 3 | 4 | export const createAccessToken = (user: User): string => { 5 | return sign({ userId: user.id }, process.env.ACCESS_TOKEN_SECRET!, { expiresIn: "15m" }); 6 | }; 7 | 8 | export const createRefreshToken = (user: User): string => { 9 | return sign( 10 | { userId: user.id, tokenVersion: user.tokenVersion }, 11 | process.env.REFRESH_TOKEN_SECRET!, 12 | { expiresIn: "7d" } 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /client/src/schemas/loginValidationSchema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | export const loginValidationSchema = yup.object({ 4 | email: yup 5 | .string() 6 | .email() 7 | .required('Email is required'), 8 | password: yup.lazy(value => 9 | !value 10 | ? yup.string() 11 | : yup 12 | .string() 13 | .min(6, 'Password must be at least 6 characters') 14 | .required('Password is required'), 15 | ), 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/components/Cards/styles/AccountsCard.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | 3 | export const useAccountsCardStyles = createUseStyles({ 4 | root: { 5 | display: 'flex', 6 | justifyContent: 'space-between', 7 | alignItems: 'center', 8 | }, 9 | svg: { 10 | width: 32, 11 | }, 12 | }); 13 | 14 | export const useNoAccountsCardStyles = createUseStyles({ 15 | root: { 16 | display: 'flex', 17 | marginTop: '62px', 18 | justifyContent: 'center', 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /client/src/components/Typography/Title.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography } from '@material-ui/core'; 3 | import { ColorScheme } from '../../utils/theme'; 4 | 5 | interface TitleProps { 6 | title: string; 7 | fontSize: number; 8 | } 9 | 10 | export const Title: React.FC = (props: TitleProps) => { 11 | return ( 12 | 15 | {props.title} 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /client/build/static/css/main.3609ce2a.chunk.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["index.css"],"names":[],"mappings":"AAIA,UAHI,WAUJ,CAPA,KACI,QAAS,CACT,wCAA0C,CAC1C,kCAAmC,CACnC,iCAAkC,CAClC,qBAEJ,CAEA,KACI,yEACJ","file":"main.3609ce2a.chunk.css","sourcesContent":["html {\n height: 100%;\n}\n\nbody {\n margin: 0;\n font-family: 'Source sans Pro', sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n background-color: snow;\n height: 100%;\n}\n\ncode {\n font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;\n}\n"]} -------------------------------------------------------------------------------- /client/build/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 | -------------------------------------------------------------------------------- /client/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 | -------------------------------------------------------------------------------- /client/src/graphql/register.graphql: -------------------------------------------------------------------------------- 1 | mutation Register( 2 | $email: String! 3 | $password: String! 4 | $firstName: String! 5 | $lastName: String! 6 | $dateOfBirth: String! 7 | $streetAddress: String! 8 | $postCode: String! 9 | $city: String! 10 | $country: String! 11 | ) { 12 | register( 13 | email: $email 14 | password: $password 15 | firsName: $firstName 16 | lastName: $lastName 17 | dateOfBirth: $dateOfBirth 18 | streetAddress: $streetAddress 19 | postCode: $postCode 20 | city: $city 21 | country: $country 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": false, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /client/src/assets/mc_symbol.svg: -------------------------------------------------------------------------------- 1 | Asset 1 -------------------------------------------------------------------------------- /server/src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from "@hapi/joi"; 2 | 3 | export const registerSchema = Joi.object({ 4 | email: Joi.string().email().required(), 5 | password: Joi.string().pattern(new RegExp("^[a-zA-Z0-9]{3,30}$")).min(6).required(), 6 | dateOfBirth: Joi.date().max(new Date()).required(), 7 | }); 8 | 9 | export const loginSchema = Joi.object({ 10 | email: Joi.string().email().required(), 11 | password: Joi.string().pattern(new RegExp("^[a-zA-Z0-9]{3,30}$")).min(6).required(), 12 | }); 13 | 14 | export const changePasswordSchema = Joi.object({ 15 | oldPassword: Joi.string(), 16 | newPassword: Joi.string().pattern(new RegExp("^[a-zA-Z0-9]{3,30}$")).min(6).required(), 17 | }); 18 | -------------------------------------------------------------------------------- /client/build/static/media/mc_symbol.543bc93b.svg: -------------------------------------------------------------------------------- 1 | Asset 1 -------------------------------------------------------------------------------- /client/src/pages/Bye.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ColorScheme } from '../utils/theme'; 3 | 4 | export const Bye: React.FC = () => { 5 | return ( 6 | <> 7 |
We're sad to see you go :(
8 |
9 |

10 | Click{' '} 11 | 12 | here 13 | {' '} 14 | to return back to the home page 15 |

16 |
17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/components/Dialog/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDialogStyles } from './Dialog.style'; 3 | 4 | interface DialogProps { 5 | isOpen: boolean; 6 | onClose: any; 7 | } 8 | 9 | export const Dialog: React.FC = ({ children, isOpen, onClose }) => { 10 | const classes = useDialogStyles(); 11 | 12 | let dialog: JSX.Element | undefined = ( 13 |
14 | 17 | {children} 18 |
19 | ); 20 | 21 | if (!isOpen) { 22 | dialog = undefined; 23 | } 24 | return
{dialog}
; 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/components/SideDrawer/DrawerToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDrawerToggleButtonStyles } from './DrawerToggleButton.style'; 3 | 4 | interface DrawerToggleButtonProps { 5 | click(): void; 6 | } 7 | 8 | export const DrawerToggleButton: React.FC = ( 9 | props: DrawerToggleButtonProps, 10 | ) => { 11 | const classes = useDrawerToggleButtonStyles(); 12 | 13 | return ( 14 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /client/src/components/SideDrawer/DrawerToggleButton.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | 3 | export const useDrawerToggleButtonStyles = createUseStyles({ 4 | toggleButton: { 5 | display: 'flex', 6 | flexDirection: 'column', 7 | justifyContent: 'space-around', 8 | height: '24px', 9 | width: '28px', 10 | background: 'transparent', 11 | cursor: 'pointer', 12 | padding: 0, 13 | border: 'none', 14 | boxSizing: 'border-box', 15 | '& :focus': { 16 | outline: 'none', 17 | }, 18 | }, 19 | toggleButtonLine: { 20 | width: '30px', 21 | height: '2px', 22 | background: 'white', 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme, Theme } from '@material-ui/core'; 2 | 3 | export enum ColorScheme { 4 | PRIMARY = '#222B2D', 5 | SECONDARY = '#29AABB', 6 | ORANGE = '#F15742', 7 | MAROON = '#432D32', 8 | WHITE = '#FFFEF9', 9 | HOVER = '#148C9C', 10 | PRIMARY_HOVER = '#090c0c', 11 | } 12 | 13 | // For Material UI 14 | export const theme: Theme = createMuiTheme({ 15 | palette: { 16 | primary: { 17 | main: ColorScheme.PRIMARY, 18 | }, 19 | secondary: { 20 | main: ColorScheme.ORANGE, 21 | }, 22 | info: { 23 | main: ColorScheme.MAROON, 24 | }, 25 | contrastThreshold: 3, 26 | tonalOffset: 0.2, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /server/src/utils/messages.ts: -------------------------------------------------------------------------------- 1 | export enum SuccessMessages { 2 | ADD_MONEY = "Successfully topped up your account.", 3 | EXCHANGE = "The exchange was successfully executed.", 4 | } 5 | 6 | export enum ErrorMessages { 7 | ADD_MONEY = "Failed to top up your account", 8 | EXCHANGE = "You do not have the sufficient funds to make this exchange.", 9 | LOGIN = "Invalid login.", 10 | PASSWORD = "Invalid password.", 11 | UPDATE_PASSWORD = "Could not change your password, are you sure you entered the correct password?", 12 | DELETE_ACCOUNT = "Failed to destroy account.", 13 | BALANCE_LESS_THAN = "Your account balance has fallen below 0. Please top up before deleting.", 14 | BALANCE_GREATER_THAN = "Your account balance is greater than 0. Please exchange your funds before deleting.", 15 | } 16 | -------------------------------------------------------------------------------- /server/src/utils/createTypeOrmConnection.ts: -------------------------------------------------------------------------------- 1 | import { getConnectionOptions, createConnection, Connection, ConnectionOptions } from "typeorm"; 2 | import { Account } from "../entity/Account"; 3 | import { User } from "../entity/User"; 4 | import { Transaction } from "../entity/Transaction"; 5 | import { Card } from "../entity/Card"; 6 | 7 | export const createTypeOrmConnection = async (): Promise => { 8 | const connectionOptions: ConnectionOptions = await getConnectionOptions(process.env.NODE_ENV); 9 | return process.env.NODE_ENV === "production" 10 | ? createConnection({ 11 | ...connectionOptions, 12 | url: process.env.DATABASE_URL, 13 | entities: [User, Account, Transaction, Card], 14 | name: "default" 15 | } as any) 16 | : createConnection({ ...connectionOptions, name: "default" }); 17 | }; 18 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], 6 | "sourceMap": true, 7 | "outDir": "./dist", 8 | "moduleResolution": "node", 9 | "removeComments": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "noImplicitThis": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "allowSyntheticDefaultImports": true, 19 | "esModuleInterop": true, 20 | "emitDecoratorMetadata": true, 21 | "experimentalDecorators": true, 22 | "resolveJsonModule": true, 23 | "baseUrl": "." 24 | }, 25 | "exclude": ["node_modules"], 26 | "include": ["./src/**/*.tsx", "./src/**/*.ts"] 27 | } 28 | -------------------------------------------------------------------------------- /client/src/components/Alerts/AlertMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert } from '@material-ui/lab'; 3 | 4 | interface AlertMessageProps { 5 | message: string; 6 | } 7 | 8 | export const SuccessMessage: React.FC = ({ message }) => { 9 | return ( 10 | 11 | {message} 12 | 13 | ); 14 | }; 15 | 16 | export const WarningMessage: React.FC = ({ message }) => { 17 | return ( 18 | 19 | {message} 20 | 21 | ); 22 | }; 23 | 24 | export const ErrorMessage: React.FC = ({ message }) => { 25 | return ( 26 | 27 | {message} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/components/Cards/styles/TransactionCard.style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; 2 | import { ColorScheme } from '../../../utils/theme'; 3 | 4 | export const useTransactionCardStyles = makeStyles((theme: Theme) => 5 | createStyles({ 6 | root: {}, 7 | expand: { 8 | transform: 'rotate(0deg)', 9 | marginLeft: 'auto', 10 | transition: theme.transitions.create('transform', { 11 | duration: theme.transitions.duration.shortest, 12 | }), 13 | }, 14 | expandOpen: { 15 | transform: 'rotate(180deg)', 16 | }, 17 | expandedText: { 18 | fontSize: 14, 19 | marginBottom: 8, 20 | }, 21 | avatar: { 22 | backgroundColor: ColorScheme.PRIMARY, 23 | }, 24 | }), 25 | ); 26 | -------------------------------------------------------------------------------- /server/src/entity/Transaction.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int } from "type-graphql"; 2 | import { Entity, BaseEntity, PrimaryGeneratedColumn, ManyToOne, Column } from "typeorm"; 3 | import { Account } from "./Account"; 4 | import { Card } from "./Card"; 5 | 6 | /** 7 | * Transactions table 8 | */ 9 | @ObjectType() 10 | @Entity("transactions") 11 | export class Transaction extends BaseEntity { 12 | @Field(() => Int) 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @ManyToOne(() => Account, (account) => account.transactions, { onDelete: "CASCADE" }) 17 | account: Account; 18 | 19 | @ManyToOne(() => Card, (card) => card.transactions, { onDelete: "CASCADE" }) 20 | card: Card; 21 | 22 | @Field() 23 | @Column() 24 | transactionType: string; 25 | 26 | @Field() 27 | @Column({ unique: true }) 28 | date: Date; 29 | 30 | @Field() 31 | @Column() 32 | amount: string; 33 | } 34 | -------------------------------------------------------------------------------- /server/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareFn } from "type-graphql"; 2 | import { MyContext } from "./MyContext"; 3 | import { verify } from "jsonwebtoken"; 4 | 5 | /** 6 | * Authentication middleware 7 | * Ensures that a user is authenticated using JSON Web Tokens 8 | * @param param0 9 | * @param next 10 | */ 11 | export const isAuth: MiddlewareFn = ({ context }, next) => { 12 | const authorization: string | undefined = context.req.headers["authorization"]; 13 | 14 | if (!authorization) { 15 | throw new Error("Not authenticated"); 16 | } 17 | 18 | try { 19 | const token: string = authorization.split(" ")[1]; 20 | const payload: string | object = verify(token, process.env.ACCESS_TOKEN_SECRET!); 21 | context.payload = payload as any; 22 | } catch (err) { 23 | console.log(err); 24 | throw new Error("Not authenticated"); 25 | } 26 | 27 | return next(); 28 | }; 29 | -------------------------------------------------------------------------------- /client/src/components/Cards/styles/ApolloCard.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | 3 | export const useApolloCardStyles = createUseStyles({ 4 | cardTop: { 5 | display: 'flex', 6 | justifyContent: 'space-between', 7 | alignItems: 'center', 8 | }, 9 | cardTypeIcon: { 10 | width: 64, 11 | }, 12 | cardNumber: { 13 | fontSize: 18, 14 | letterSpacing: 10, 15 | textAlign: 'center', 16 | marginTop: 32, 17 | marginBottom: 24, 18 | }, 19 | cardValidThruLabel: { 20 | textTransform: 'uppercase', 21 | letterSpacing: 0.5, 22 | fontSize: 12, 23 | opacity: 0.9, 24 | }, 25 | cardCvvLabel: { 26 | textTransform: 'uppercase', 27 | letterSpacing: 0.5, 28 | marginLeft: 12, 29 | fontSize: 12, 30 | opacity: 0.9, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/pages/Login/Login.style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles'; 2 | import { ColorScheme } from '../../utils/theme'; 3 | 4 | export const useLoginStyles = makeStyles({ 5 | root: { 6 | margin: '0 auto', 7 | top: '25%', 8 | height: '100%', 9 | width: '348px', 10 | }, 11 | headerText: { 12 | textAlign: 'center', 13 | fontWeight: 'bold', 14 | }, 15 | formField: { 16 | width: '100%', 17 | marginTop: 12, 18 | }, 19 | formButton: { 20 | marginTop: 12, 21 | textTransform: 'none', 22 | fontWeight: 'bold', 23 | letterSpacing: 1, 24 | textAlign: 'center', 25 | '&:disabled': { 26 | backgroundColor: ColorScheme.ORANGE, 27 | color: ColorScheme.WHITE, 28 | }, 29 | }, 30 | registerText: { 31 | marginTop: 12, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /server/src/entity/Card.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int } from "type-graphql"; 2 | import { Entity, BaseEntity, PrimaryGeneratedColumn, ManyToOne, OneToMany, Column } from "typeorm"; 3 | import { Transaction } from "./Transaction"; 4 | import { User } from "./User"; 5 | 6 | /** 7 | * Cards table 8 | */ 9 | @ObjectType() 10 | @Entity("cards") 11 | export class Card extends BaseEntity { 12 | @Field(() => Int) 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @ManyToOne(() => User, (user) => user.cards, { onDelete: "CASCADE" }) 17 | owner: User; 18 | 19 | @OneToMany(() => Transaction, (transaction) => transaction.card, { onDelete: "CASCADE" }) 20 | transactions: Transaction[]; 21 | 22 | @Field() 23 | @Column() 24 | cardNumber: string; 25 | 26 | @Field() 27 | @Column() 28 | pin: number; 29 | 30 | @Field() 31 | @Column() 32 | expiresIn: Date; 33 | 34 | @Field() 35 | @Column() 36 | cvv: number; 37 | 38 | @Field() 39 | @Column() 40 | monthlySpendingLimit: number; 41 | } 42 | -------------------------------------------------------------------------------- /client/src/pages/Dashboard/styles/Dashboard.style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core'; 2 | import { ColorScheme } from '../../../utils/theme'; 3 | 4 | export const useDashboardStyles = makeStyles(theme => ({ 5 | root: { 6 | display: 'flex', 7 | }, 8 | content: { 9 | flexGrow: 1, 10 | height: '100vh', 11 | overflow: 'auto', 12 | }, 13 | container: { 14 | paddingTop: theme.spacing(1), 15 | paddingBottom: theme.spacing(4), 16 | }, 17 | paper: { 18 | fontWeight: 'bold', 19 | padding: theme.spacing(2), 20 | display: 'flex', 21 | overflow: 'auto', 22 | flexDirection: 'column', 23 | borderRadius: 8, 24 | }, 25 | fixedHeight: { 26 | height: 240, 27 | }, 28 | accountCardHeight: { 29 | height: 172, 30 | }, 31 | chart: { 32 | height: '100%', 33 | }, 34 | apolloCard: { 35 | backgroundColor: ColorScheme.ORANGE, 36 | color: ColorScheme.WHITE, 37 | }, 38 | })); 39 | -------------------------------------------------------------------------------- /server/src/entity/Account.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int } from "type-graphql"; 2 | import { BaseEntity, PrimaryGeneratedColumn, Entity, Column, ManyToOne, OneToMany } from "typeorm"; 3 | import { User } from "./User"; 4 | import { Transaction } from "./Transaction"; 5 | 6 | /** 7 | * Accounts table 8 | */ 9 | @ObjectType() 10 | @Entity("accounts") 11 | export class Account extends BaseEntity { 12 | @Field(() => Int) 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @ManyToOne(() => User, (owner) => owner.accounts, { onDelete: "CASCADE" }) 17 | owner: User; 18 | 19 | @Field() 20 | @Column({ default: "00-00-00", nullable: true }) 21 | sortCode: string; 22 | 23 | @Field() 24 | @Column({ nullable: true }) 25 | iban: string; 26 | 27 | @Field() 28 | @Column({ nullable: true }) 29 | bic: string; 30 | 31 | @Field() 32 | @Column() 33 | currency: string; 34 | 35 | @Field() 36 | @Column({ default: 1000 }) 37 | balance: number; 38 | 39 | @OneToMany(() => Transaction, (transaction) => transaction.account, { onDelete: "CASCADE" }) 40 | transactions: Transaction[]; 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Edward Clark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ColorScheme } from '../utils/theme'; 3 | 4 | interface Props {} 5 | 6 | export const Home: React.FC = () => { 7 | return ( 8 |
9 |
17 |
26 | APOLLO 27 |
28 |
Banking made easy.
29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /client/src/pages/Register/Register.style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles'; 2 | import { ColorScheme } from '../../utils/theme'; 3 | 4 | export const useRegisterStyles = makeStyles({ 5 | root: { 6 | margin: '0 auto', 7 | top: '25%', 8 | height: '100%', 9 | width: '348px', 10 | }, 11 | headerText: { 12 | textAlign: 'center', 13 | fontWeight: 'bold', 14 | }, 15 | alignedFormContent: { 16 | marginTop: 12, 17 | display: 'flex', 18 | width: '100%', 19 | }, 20 | alignedFormField: { 21 | width: '50%', 22 | }, 23 | formField: { 24 | width: '100%', 25 | marginTop: 12, 26 | }, 27 | formButton: { 28 | marginTop: 12, 29 | textTransform: 'none', 30 | fontWeight: 'bold', 31 | letterSpacing: 1, 32 | textAlign: 'center', 33 | '&:disabled': { 34 | backgroundColor: ColorScheme.ORANGE, 35 | color: ColorScheme.WHITE, 36 | }, 37 | }, 38 | loginText: { 39 | margintop: 12, 40 | }, 41 | spacer: { 42 | width: 8, 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /server/src/utils/createRandom.ts: -------------------------------------------------------------------------------- 1 | export const createRandomNumber = (n: number): string => { 2 | let add = 1, 3 | max = 12 - add; 4 | 5 | if (n > max) { 6 | return createRandomNumber(max) + createRandomNumber(n - max); 7 | } 8 | 9 | max = Math.pow(10, n + add); 10 | let min = max / 10; 11 | let randomNumber = Math.floor(Math.random() * (max - min + 1)) + min; 12 | return ("" + randomNumber).substring(add); 13 | }; 14 | 15 | export const createRandomIbanCode = (): string => { 16 | return `GB${createRandomNumber(2)} AP0L ${createRandomNumber(4)} ${createRandomNumber( 17 | 4 18 | )} ${createRandomNumber(4)} ${createRandomNumber(2)}`; 19 | }; 20 | 21 | export const createRandomBicCode = (): string => { 22 | return `AP0LGB${createRandomNumber(2)}`; 23 | }; 24 | 25 | export const createRandomSortCode = (): string | undefined => { 26 | let randomNumber = Math.floor(Math.random() * 899999 + 100000); 27 | return randomNumber 28 | .toString() 29 | .match(/.{1,2}/g) 30 | ?.join("-"); 31 | }; 32 | 33 | export const createRandomCardNumber = (): string => { 34 | return `${createRandomNumber(4)} ${createRandomNumber(4)} ${createRandomNumber( 35 | 4 36 | )} ${createRandomNumber(4)}`; 37 | }; 38 | -------------------------------------------------------------------------------- /server/ormconfig.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "production", 4 | "type": "postgres", 5 | "synchronize": true, 6 | "logging": true 7 | }, 8 | { 9 | "name": "development", 10 | "type": "postgres", 11 | "host": "localhost", 12 | "port": 5432, 13 | "username": "postgres", 14 | "password": "postgres", 15 | "database": "apollobank", 16 | "synchronize": true, 17 | "logging": false, 18 | "entities": ["src/entity/**/*.ts"], 19 | "migrations": ["src/migration/**/*.ts"], 20 | "subscribers": ["src/subscriber/**/*.ts"], 21 | "cli": { 22 | "entitiesDir": "src/entity", 23 | "migrationsDir": "src/migration", 24 | "subscribersDir": "src/subscriber" 25 | } 26 | }, 27 | { 28 | "name": "default", 29 | "type": "postgres", 30 | "host": "localhost", 31 | "port": 5432, 32 | "username": "postgres", 33 | "password": "postgres", 34 | "database": "apollobank", 35 | "synchronize": true, 36 | "logging": false, 37 | "entities": ["src/entity/**/*.ts"], 38 | "migrations": ["src/migration/**/*.ts"], 39 | "subscribers": ["src/subscriber/**/*.ts"], 40 | "cli": { 41 | "entitiesDir": "src/entity", 42 | "migrationsDir": "src/migration", 43 | "subscribersDir": "src/subscriber" 44 | } 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /client/src/schemas/registerValidationSchema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | export const registerValidationSchema = yup.object({ 4 | firstName: yup.string().required('First name is required'), 5 | lastName: yup.string().required('Last name is required'), 6 | email: yup 7 | .string() 8 | .email() 9 | .required('Email is required'), 10 | streetAddres: yup.string().required('Street address is required'), 11 | postCode: yup.string().required('Post code is required'), 12 | city: yup.string().required('City is required'), 13 | country: yup.string().required('Country is required'), 14 | password: yup.lazy(value => 15 | !value 16 | ? yup.string() 17 | : yup 18 | .string() 19 | .min(6, 'Password must be at least 6 characters') 20 | .required('Password is required'), 21 | ), 22 | confirmPassword: yup.string().oneOf([yup.ref('password')], 'Passwords do not match'), 23 | dateOfBirth: yup 24 | .date() 25 | .max(new Date(), 'Date of birth cannot be in the future') 26 | .typeError('Date of birth has to be a valid date') 27 | .required('Date of birth is required'), 28 | }); 29 | -------------------------------------------------------------------------------- /server/src/entity/User.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryGeneratedColumn, Column, BaseEntity, OneToMany } from "typeorm"; 2 | import { ObjectType, Field, Int } from "type-graphql"; 3 | import { Account } from "./Account"; 4 | import { Card } from "./Card"; 5 | 6 | /** 7 | * Users table 8 | */ 9 | @ObjectType() 10 | @Entity("users") 11 | export class User extends BaseEntity { 12 | @Field(() => Int) 13 | @PrimaryGeneratedColumn() 14 | id: number; 15 | 16 | @Field() 17 | @Column({ unique: true }) 18 | email: string; 19 | 20 | @Column() 21 | password: string; 22 | 23 | @Field() 24 | @Column() 25 | firstName: string; 26 | 27 | @Field() 28 | @Column() 29 | lastName: string; 30 | 31 | @Field() 32 | @Column() 33 | dateOfBirth: string; 34 | 35 | @Field() 36 | @Column() 37 | streetAddress: string; 38 | 39 | @Field() 40 | @Column() 41 | postCode: string; 42 | 43 | @Field() 44 | @Column() 45 | city: string; 46 | 47 | @Field() 48 | @Column() 49 | country: string; 50 | 51 | @OneToMany(() => Account, (account) => account.owner, { onDelete: "CASCADE" }) 52 | accounts: Account[]; 53 | 54 | @OneToMany(() => Card, (card) => card.owner, { onDelete: "CASCADE" }) 55 | cards: Card[]; 56 | 57 | @Column("int", { default: 0 }) 58 | tokenVersion: number; 59 | } 60 | -------------------------------------------------------------------------------- /client/src/components/Dialog/Dialog.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | import { ColorScheme } from '../../utils/theme'; 3 | 4 | export const useDialogStyles = createUseStyles({ 5 | dialog: { 6 | width: '500px', 7 | maxWidth: '100%', 8 | margin: '0 auto', 9 | position: 'fixed', 10 | left: '50%', 11 | top: '50%', 12 | transform: 'translate(-50%, -50%)', 13 | zIndex: 999, 14 | backgroundColor: 'white', 15 | padding: '10px 20px 40px', 16 | borderRadius: '8px', 17 | display: 'flex', 18 | flexDirection: 'column', 19 | boxShadow: '3px 7px 18px 0px rgba(148,148,148,1)', 20 | }, 21 | closeButton: { 22 | backgroundColor: ColorScheme.PRIMARY, 23 | color: ColorScheme.WHITE, 24 | fontSize: 16, 25 | outline: 'none', 26 | marginBottom: '15px', 27 | padding: '3px 8px', 28 | cursor: 'pointer', 29 | borderRadius: '50%', 30 | border: 'none', 31 | width: '30px', 32 | height: '30px', 33 | fontWeight: 'bold', 34 | alignSelf: 'flex-end', 35 | '&:hover': { 36 | backgroundColor: ColorScheme.PRIMARY_HOVER, 37 | }, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.1", 4 | "description": "Awesome project developed with TypeORM.", 5 | "devDependencies": { 6 | "@types/bcryptjs": "^2.4.2", 7 | "@types/cookie-parser": "^1.4.2", 8 | "@types/cors": "^2.8.6", 9 | "@types/express": "^4.17.3", 10 | "@types/graphql": "^14.5.0", 11 | "@types/jsonwebtoken": "^8.3.8", 12 | "@types/node": "^13.7.7", 13 | "minimist": "^1.2.5", 14 | "nodemon": "^2.0.14", 15 | "ts-node": "8.6.2", 16 | "typescript": "3.8.3" 17 | }, 18 | "dependencies": { 19 | "@hapi/joi": "^17.1.1", 20 | "@types/faker": "^4.1.10", 21 | "@types/hapi__joi": "^16.0.12", 22 | "apollo-server-express": "^2.17.0", 23 | "bcryptjs": "^2.4.3", 24 | "cookie-parser": "^1.4.4", 25 | "cors": "^2.8.5", 26 | "dotenv": "^8.2.0", 27 | "express": "^4.17.1", 28 | "faker": "^4.1.0", 29 | "graphql": "^14.6.0", 30 | "jsonwebtoken": "^8.5.1", 31 | "pg": "^8.4.0", 32 | "reflect-metadata": "^0.1.13", 33 | "type-graphql": "^0.17.6", 34 | "typeorm": "^0.2.38" 35 | }, 36 | "scripts": { 37 | "start": "ts-node src/index.ts", 38 | "dev": "nodemon --exec ts-node src/index.ts", 39 | "build": "tsc", 40 | "preinstall": "npx npm-force-resolutions" 41 | }, 42 | "resolutions": { 43 | "minimist": "^1.2.5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/build/precache-manifest.1f2247de76a6c560e3bc294074f75a8b.js: -------------------------------------------------------------------------------- 1 | self.__precacheManifest = (self.__precacheManifest || []).concat([ 2 | { 3 | "revision": "21e9ceb5cde8f1f7a505cdeac1bfdeb6", 4 | "url": "/index.html" 5 | }, 6 | { 7 | "revision": "0596feb02e91b353ba83", 8 | "url": "/static/css/main.3609ce2a.chunk.css" 9 | }, 10 | { 11 | "revision": "634750f8c2e86629aa39", 12 | "url": "/static/js/2.76c6d5bb.chunk.js" 13 | }, 14 | { 15 | "revision": "3b22f900d4f81232c73c229663f8fe41", 16 | "url": "/static/js/2.76c6d5bb.chunk.js.LICENSE.txt" 17 | }, 18 | { 19 | "revision": "0596feb02e91b353ba83", 20 | "url": "/static/js/main.161267fe.chunk.js" 21 | }, 22 | { 23 | "revision": "cda345fb50b7e11c0025", 24 | "url": "/static/js/runtime-main.844668cf.js" 25 | }, 26 | { 27 | "revision": "36ab476e5e55f496749ee61897a9cfb5", 28 | "url": "/static/media/flag.36ab476e.svg" 29 | }, 30 | { 31 | "revision": "543bc93b2e32281bad1ede21bb3afbdd", 32 | "url": "/static/media/mc_symbol.543bc93b.svg" 33 | }, 34 | { 35 | "revision": "e5564902e2642c5e6e2e98e68a7d41f5", 36 | "url": "/static/media/uk.e5564902.svg" 37 | }, 38 | { 39 | "revision": "7aea52bbbc38e6d8f93bf6f50c467452", 40 | "url": "/static/media/world.7aea52bb.svg" 41 | } 42 | ]); -------------------------------------------------------------------------------- /client/build/service-worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Welcome to your Workbox-powered service worker! 3 | * 4 | * You'll need to register this file in your web app and you should 5 | * disable HTTP caching for this file too. 6 | * See https://goo.gl/nhQhGp 7 | * 8 | * The rest of the code is auto-generated. Please don't update this file 9 | * directly; instead, make changes to your Workbox build configuration 10 | * and re-run your build process. 11 | * See https://goo.gl/2aRDsh 12 | */ 13 | 14 | importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js"); 15 | 16 | importScripts( 17 | "/precache-manifest.1f2247de76a6c560e3bc294074f75a8b.js" 18 | ); 19 | 20 | self.addEventListener('message', (event) => { 21 | if (event.data && event.data.type === 'SKIP_WAITING') { 22 | self.skipWaiting(); 23 | } 24 | }); 25 | 26 | workbox.core.clientsClaim(); 27 | 28 | /** 29 | * The workboxSW.precacheAndRoute() method efficiently caches and responds to 30 | * requests for URLs in the manifest. 31 | * See https://goo.gl/S9QRab 32 | */ 33 | self.__precacheManifest = [].concat(self.__precacheManifest || []); 34 | workbox.precaching.precacheAndRoute(self.__precacheManifest, {}); 35 | 36 | workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("/index.html"), { 37 | 38 | blacklist: [/^\/_/,/\/[^/?]+\.[^/]+$/], 39 | }); 40 | -------------------------------------------------------------------------------- /client/build/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "main.css": "/static/css/main.3609ce2a.chunk.css", 4 | "main.js": "/static/js/main.161267fe.chunk.js", 5 | "main.js.map": "/static/js/main.161267fe.chunk.js.map", 6 | "runtime-main.js": "/static/js/runtime-main.844668cf.js", 7 | "runtime-main.js.map": "/static/js/runtime-main.844668cf.js.map", 8 | "static/js/2.76c6d5bb.chunk.js": "/static/js/2.76c6d5bb.chunk.js", 9 | "static/js/2.76c6d5bb.chunk.js.map": "/static/js/2.76c6d5bb.chunk.js.map", 10 | "index.html": "/index.html", 11 | "precache-manifest.1f2247de76a6c560e3bc294074f75a8b.js": "/precache-manifest.1f2247de76a6c560e3bc294074f75a8b.js", 12 | "service-worker.js": "/service-worker.js", 13 | "static/css/main.3609ce2a.chunk.css.map": "/static/css/main.3609ce2a.chunk.css.map", 14 | "static/js/2.76c6d5bb.chunk.js.LICENSE.txt": "/static/js/2.76c6d5bb.chunk.js.LICENSE.txt", 15 | "static/media/flag.36ab476e.svg": "/static/media/flag.36ab476e.svg", 16 | "static/media/mc_symbol.543bc93b.svg": "/static/media/mc_symbol.543bc93b.svg", 17 | "static/media/uk.e5564902.svg": "/static/media/uk.e5564902.svg", 18 | "static/media/world.7aea52bb.svg": "/static/media/world.7aea52bb.svg" 19 | }, 20 | "entrypoints": [ 21 | "static/js/runtime-main.844668cf.js", 22 | "static/js/2.76c6d5bb.chunk.js", 23 | "static/css/main.3609ce2a.chunk.css", 24 | "static/js/main.161267fe.chunk.js" 25 | ] 26 | } -------------------------------------------------------------------------------- /client/src/components/SideDrawer/SideDrawer.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | import { ColorScheme } from '../../utils/theme'; 3 | 4 | export const useSideDrawerStyles = createUseStyles({ 5 | siderDrawer: { 6 | height: '100%', 7 | background: ColorScheme.WHITE, 8 | boxShadow: '1px 0 7px rgba(0,0,0,0.5)', 9 | position: 'fixed', 10 | top: 0, 11 | left: 0, 12 | width: '70%', 13 | maxWidth: '280px', 14 | zIndex: 200, 15 | transform: 'translateX(-100%)', 16 | transition: 'transform 0.3s ease-out', 17 | '& ul': { 18 | height: '100%', 19 | listStyle: 'none', 20 | display: 'flex', 21 | flexDirection: 'column', 22 | justifyContent: 'center', 23 | }, 24 | '& li': { 25 | margin: '0.5rem 0', 26 | }, 27 | '& a': { 28 | color: ColorScheme.PRIMARY, 29 | textDecoration: 'none', 30 | fontSize: '1.2rem', 31 | }, 32 | '& a:hover': { 33 | color: ColorScheme.ORANGE, 34 | }, 35 | '& a:active': { 36 | color: ColorScheme.ORANGE, 37 | }, 38 | }, 39 | open: { 40 | transform: 'translateX(0)', 41 | }, 42 | '@media (min-width: 769px)': { 43 | siderDrawer: { 44 | display: 'none', 45 | }, 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /client/build/Bitcoin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/public/Bitcoin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/build/static/js/runtime-main.844668cf.js: -------------------------------------------------------------------------------- 1 | !function(e){function t(t){for(var n,l,i=t[0],f=t[1],a=t[2],p=0,s=[];p { 9 | const [loading, setLoading] = useState(true); 10 | 11 | // Fetch refresh token 12 | useEffect(() => { 13 | fetch((process.env.REACT_APP_SERVER_URL as string) + '/refresh_token', { 14 | method: 'POST', 15 | credentials: 'include', 16 | }).then(async res => { 17 | const { accessToken } = await res.json(); 18 | setAccessToken(accessToken); 19 | setLoading(false); 20 | }); 21 | }, []); 22 | 23 | if (loading) { 24 | return ( 25 | <> 26 | 27 | 28 | 29 |
37 | 38 |
39 | 40 | ); 41 | } 42 | 43 | return ( 44 | <> 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /client/build/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/public/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/src/components/Forms/FormTextField.tsx: -------------------------------------------------------------------------------- 1 | import { FieldAttributes, useField } from 'formik'; 2 | import React from 'react'; 3 | import { ThemeProvider, TextField } from '@material-ui/core'; 4 | import { theme } from '../../utils/theme'; 5 | 6 | export const FormTextField: React.FC> = ({ 7 | placeholder, 8 | className, 9 | type, 10 | ...props 11 | }) => { 12 | const [field, meta] = useField<{}>(props); 13 | const errorText: string = meta.error && meta.touched ? meta.error : ''; 14 | 15 | return ( 16 | 17 | 27 | 28 | ); 29 | }; 30 | 31 | export const FormDatePicker: React.FC> = ({ 32 | placeholder, 33 | className, 34 | ...props 35 | }) => { 36 | const [field, meta] = useField<{}>(props); 37 | const errorText: string = meta.error && meta.touched ? meta.error : ''; 38 | 39 | return ( 40 | 41 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /server/src/resolvers/CardResolver.ts: -------------------------------------------------------------------------------- 1 | import { createRandomCardNumber, createRandomNumber } from "./../utils/createRandom"; 2 | import { MyContext } from "./../MyContext"; 3 | import { isAuth } from "../middleware"; 4 | import { Resolver, Mutation, UseMiddleware, Ctx, Query } from "type-graphql"; 5 | import { User } from "../entity/User"; 6 | import { Account } from "../entity/Account"; 7 | import { Card } from "../entity/Card"; 8 | 9 | @Resolver() 10 | export class CardResolver { 11 | /** 12 | * Query for returning all the cards for an authenticated user 13 | * @param param0 14 | */ 15 | @Query(() => [Card]) 16 | @UseMiddleware(isAuth) 17 | async cards(@Ctx() { payload }: MyContext) { 18 | if (!payload) { 19 | return null; 20 | } 21 | 22 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 23 | 24 | if (owner) { 25 | const account: Account | undefined = await Account.findOne({ where: { owner: owner } }); 26 | 27 | if (account) { 28 | const cards = await Card.find({ where: { account: account } }); 29 | return cards; 30 | } 31 | } 32 | return null; 33 | } 34 | 35 | /** 36 | * Mutation for creating a new card 37 | * @param param0 38 | */ 39 | @Mutation(() => Boolean) 40 | @UseMiddleware(isAuth) 41 | async createCard(@Ctx() { payload }: MyContext) { 42 | if (!payload) { 43 | return false; 44 | } 45 | 46 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 47 | 48 | if (owner) { 49 | try { 50 | await Card.insert({ 51 | owner, 52 | cardNumber: createRandomCardNumber(), 53 | expiresIn: new Date(2023, 9), 54 | pin: parseInt(createRandomNumber(4)), 55 | cvv: parseInt(createRandomNumber(3)), 56 | monthlySpendingLimit: 500, 57 | }); 58 | } catch (err) { 59 | console.log(err); 60 | return false; 61 | } 62 | } 63 | 64 | return true; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /client/src/pages/Accounts/styles/Account.style.ts: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core'; 2 | import { ColorScheme } from '../../../utils/theme'; 3 | 4 | export const useAccountStyles = makeStyles({ 5 | root: { 6 | margin: '0 auto', 7 | marginTop: 24, 8 | }, 9 | accountBalance: { 10 | fontSize: 28, 11 | textAlign: 'center', 12 | }, 13 | accountInfo: { 14 | display: 'flex', 15 | justifyContent: 'space-evenly', 16 | width: '240px', 17 | margin: '0 auto', 18 | marginTop: 12, 19 | alignItems: 'center', 20 | }, 21 | accountButtonsSection: { 22 | textAlign: 'center', 23 | display: 'flex', 24 | justifyContent: 'space-evenly', 25 | alignItems: 'center', 26 | margin: '0 auto', 27 | width: '480px', 28 | marginTop: 24, 29 | }, 30 | accountButton: { 31 | backgroundColor: ColorScheme.PRIMARY, 32 | color: ColorScheme.WHITE, 33 | '&:hover': { 34 | background: ColorScheme.PRIMARY_HOVER, 35 | }, 36 | }, 37 | accountButtonSection: { 38 | display: 'flex', 39 | flexDirection: 'column', 40 | alignItems: 'center', 41 | justifyContent: 'center', 42 | }, 43 | accountButtonText: { 44 | marginTop: 12, 45 | fontSize: 14, 46 | }, 47 | transactions: { 48 | marginTop: 12, 49 | margin: '0 auto', 50 | display: 'flex', 51 | flexDirection: 'column', 52 | width: 480, 53 | }, 54 | transactionsHeader: { 55 | marginTop: 12, 56 | display: 'flex', 57 | }, 58 | transactionCards: { 59 | marginTop: 8, 60 | }, 61 | dialogButton: { 62 | marginTop: 12, 63 | textTransform: 'none', 64 | fontWeight: 'bold', 65 | letterSpacing: 1, 66 | textAlign: 'center', 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 18 | 19 | 28 | Apollo Bank - Banking made easy. 29 | 30 | 31 | 32 |
33 | 43 | 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # apollobank 🚀 2 | 3 | A fullstack GraphQL banking application built using React, Node & TypeScript. 4 | 5 | 🔥Any contribution activity including finding/report/fixing issues, and pull requests are Welcome!👋
6 | Now it is fully open source. Check the contribution guide [here](CONTRIBUTING.md). 7 | 8 | ## Running 9 | 10 | ### Prerequirement 11 | - Node.js 12 | - PostgreSQL 13 13 | - create database name "apollobank" 14 | - Git clone 15 | ```bash 16 | git clone https://github.com/edwardcdev/apollobank.git 17 | cd apollobank 18 | ``` 19 | 20 | ### Run backend 21 | ```bash 22 | cd server 23 | npm install 24 | npm start 25 | ``` 26 | - check ormconfig.json file to check or update database connection info. 27 | 28 | ### Run frontend 29 | ```bash 30 | cd client 31 | npm install 32 | npm start 33 | ``` 34 | - It will server at http://localhost:3000/ 35 | ![dashboard](images/first.png) 36 | 37 | ### Using 38 | - Register fist. 39 | ![dashboard](images/register.png) 40 | - And then login. 41 | ![dashboard](images/blank.png) 42 | - Add account and transaction! Play it! 43 | ![dashboard](images/dashboard.png) 44 | 45 | ## Functions 46 | 47 | - Login/register 48 | - Dashboard 49 | - Accounts 50 | - Transactions 51 | - Credit cards 52 | - Settings 53 | - Spending for this month chart 54 | - Dummy data generator using faker 55 | 56 | ## Tech Stack 57 | 58 | ### Server side 59 | 60 | - Apollo Server 61 | - bcryptjs 62 | - cors 63 | - Express 64 | - GraphQL 65 | - faker 66 | - jsonwebtoken 67 | - TypeGraphQL 68 | - TypeORM 69 | - TypeScript 70 | - PostgreSQL 71 | 72 | ### Client side 73 | 74 | - Apollo React Hooks 75 | - FontAwesome Icons 76 | - Material UI 77 | - Recharts 78 | - Formik 79 | - Yup 80 | 81 | ## Todo 82 | 83 | - [ ] Don't allow the user to destroy an account if they are in debt or their account balance > 0 84 | - [ ] When deleting and destroying an account, alert the user with another dialog to check if they would like to proceed with this action. 85 | - [ ] Update the chart on the dashboard to show spending such that the y axis is the users account balance. 86 | - [ ] Sort transactions by date & sort chart data by date. 87 | - [ ] Fetch exchange rates from an API. 88 | -------------------------------------------------------------------------------- /client/build/index.html: -------------------------------------------------------------------------------- 1 | Apollo Bank - Banking made easy.
-------------------------------------------------------------------------------- /client/src/assets/world.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 11 | 13 | 15 | 17 | 19 | 21 | 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 | -------------------------------------------------------------------------------- /client/build/static/media/world.7aea52bb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 11 | 13 | 15 | 17 | 19 | 21 | 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 | -------------------------------------------------------------------------------- /client/src/assets/flag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 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 | -------------------------------------------------------------------------------- /client/build/static/media/flag.36ab476e.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | 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 | -------------------------------------------------------------------------------- /client/src/components/Toolbar/Toolbar.style.ts: -------------------------------------------------------------------------------- 1 | import { createUseStyles } from 'react-jss'; 2 | import { ColorScheme } from '../../utils/theme'; 3 | 4 | export const useToolbarStyles = createUseStyles({ 5 | toolbar: { 6 | // position: 'fixed', 7 | top: 0, 8 | left: 0, 9 | width: '100%', 10 | height: '56px', 11 | backgroundColor: ColorScheme.PRIMARY, 12 | }, 13 | navigation: { 14 | display: 'flex', 15 | height: '100%', 16 | alignItems: 'center', 17 | padding: '0 1rem', 18 | }, 19 | logo: { 20 | textTransform: 'uppercase', 21 | letterSpacing: 2, 22 | marginLeft: '1.5rem', 23 | '& a': { 24 | color: ColorScheme.WHITE, 25 | textDecoration: 'none', 26 | fontSize: '1.5rem', 27 | }, 28 | }, 29 | toggleButton: {}, 30 | navigationItems: { 31 | '& ul': { 32 | listStyle: 'none', 33 | margin: 0, 34 | padding: 0, 35 | display: 'flex', 36 | alignItems: 'center', 37 | }, 38 | '& li': { 39 | padding: '0 0.5rem', 40 | }, 41 | '& a': { 42 | color: ColorScheme.WHITE, 43 | letterSpacing: 1, 44 | textDecoration: 'none', 45 | fontWeight: 'bold', 46 | }, 47 | '& a:hover': { 48 | color: ColorScheme.SECONDARY, 49 | }, 50 | '& a:active': { 51 | color: ColorScheme.SECONDARY, 52 | }, 53 | }, 54 | navButton: { 55 | fontSize: 14, 56 | fontWeight: 'bold', 57 | letterSpacing: 1, 58 | marginLeft: '1rem', 59 | height: 32, 60 | width: 102, 61 | borderRadius: 4, 62 | border: 'none', 63 | backgroundColor: ColorScheme.SECONDARY, 64 | color: ColorScheme.WHITE, 65 | cursor: 'pointer', 66 | '&:hover': { 67 | backgroundColor: ColorScheme.HOVER, 68 | }, 69 | '&:focus': { 70 | outline: 0, 71 | }, 72 | }, 73 | spacer: { 74 | flex: 1, 75 | }, 76 | '@media (max-width: 768px)': { 77 | navigationItems: { 78 | display: 'none', 79 | }, 80 | }, 81 | '@media (min-width: 769px)': { 82 | toggleButton: { 83 | display: 'none', 84 | }, 85 | logo: { 86 | marginLeft: 0, 87 | }, 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /client/src/components/Cards/ApolloCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent } from 'react'; 2 | import { ThemeProvider, Button } from '@material-ui/core'; 3 | import AddIcon from '@material-ui/icons/Add'; 4 | import { theme } from '../../utils/theme'; 5 | import { ReactComponent as MasterCard } from '../../assets/mc_symbol.svg'; 6 | import { useApolloCardStyles } from './styles/ApolloCard.style'; 7 | 8 | interface ApolloCardProps { 9 | cardNumber?: string; 10 | validThru?: string; 11 | cvv?: number; 12 | onCreateNewCardClicked?(e: MouseEvent): void; 13 | } 14 | 15 | export const ApolloCard: React.FC = ({ cardNumber, validThru, cvv }) => { 16 | const classes = useApolloCardStyles(); 17 | 18 | return ( 19 | <> 20 |
21 |
22 | 23 | 🚀 24 | 25 |
26 |
27 | 28 |
29 |
30 |
{cardNumber}
31 |
32 |
33 |
valid thru
34 |
{validThru}
35 |
36 |
37 |
cvv
38 |
{cvv}
39 |
40 |
41 | 42 | ); 43 | }; 44 | 45 | export const NoApolloCard: React.FC = ({ onCreateNewCardClicked }) => { 46 | return ( 47 |
54 | 55 | 64 | 65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /client/build/static/js/2.76c6d5bb.chunk.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2017 Jed Watson. 9 | Licensed under the MIT License (MIT), see 10 | http://jedwatson.github.io/classnames 11 | */ 12 | 13 | /*! ***************************************************************************** 14 | Copyright (c) Microsoft Corporation. All rights reserved. 15 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 16 | this file except in compliance with the License. You may obtain a copy of the 17 | License at http://www.apache.org/licenses/LICENSE-2.0 18 | 19 | THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 20 | KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED 21 | WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, 22 | MERCHANTABLITY OR NON-INFRINGEMENT. 23 | 24 | See the Apache Version 2.0 License for specific language governing permissions 25 | and limitations under the License. 26 | ***************************************************************************** */ 27 | 28 | /*! decimal.js-light v2.5.0 https://github.com/MikeMcl/decimal.js-light/LICENCE */ 29 | 30 | /** 31 | * A better abstraction over CSS. 32 | * 33 | * @copyright Oleg Isonen (Slobodskoi) / Isonen 2014-present 34 | * @website https://github.com/cssinjs/jss 35 | * @license MIT 36 | */ 37 | 38 | /** @license React v0.18.0 39 | * scheduler.production.min.js 40 | * 41 | * Copyright (c) Facebook, Inc. and its affiliates. 42 | * 43 | * This source code is licensed under the MIT license found in the 44 | * LICENSE file in the root directory of this source tree. 45 | */ 46 | 47 | /** @license React v16.12.0 48 | * react-dom.production.min.js 49 | * 50 | * Copyright (c) Facebook, Inc. and its affiliates. 51 | * 52 | * This source code is licensed under the MIT license found in the 53 | * LICENSE file in the root directory of this source tree. 54 | */ 55 | 56 | /** @license React v16.12.0 57 | * react-is.production.min.js 58 | * 59 | * Copyright (c) Facebook, Inc. and its affiliates. 60 | * 61 | * This source code is licensed under the MIT license found in the 62 | * LICENSE file in the root directory of this source tree. 63 | */ 64 | 65 | /** @license React v16.12.0 66 | * react.production.min.js 67 | * 68 | * Copyright (c) Facebook, Inc. and its affiliates. 69 | * 70 | * This source code is licensed under the MIT license found in the 71 | * LICENSE file in the root directory of this source tree. 72 | */ 73 | -------------------------------------------------------------------------------- /client/src/components/Charts/Chart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | ResponsiveContainer, 4 | AreaChart, 5 | Area, 6 | XAxis, 7 | YAxis, 8 | CartesianGrid, 9 | Tooltip, 10 | Legend, 11 | } from 'recharts'; 12 | import { 13 | useTransactionsQuery, 14 | TransactionsQueryResult, 15 | Transaction, 16 | } from '../../generated/graphql'; 17 | import { useChartStyles } from './Chart.style'; 18 | 19 | interface ChartProps { 20 | currency: string; 21 | } 22 | 23 | export const Chart: React.FC = ({ currency }) => { 24 | // GraphQL queries 25 | const { data }: TransactionsQueryResult = useTransactionsQuery({ 26 | variables: { currency: currency }, 27 | }); 28 | 29 | const classes = useChartStyles(); 30 | 31 | return ( 32 | <> 33 |
34 |
Spending (this month)
35 | 36 | { 40 | return { 41 | date: new Date( 42 | Date.parse(transaction.date), 43 | ).toLocaleDateString(), 44 | type: transaction.transactionType, 45 | amount: transaction.amount, 46 | }; 47 | }) 48 | : [] 49 | } 50 | margin={{ 51 | top: 24, 52 | right: 30, 53 | left: 20, 54 | bottom: 5, 55 | }} 56 | > 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /client/src/assets/uk.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 24 | 26 | 28 | 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 | -------------------------------------------------------------------------------- /client/build/static/media/uk.e5564902.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 24 | 26 | 28 | 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 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import { ApolloServer } from "apollo-server-express"; 3 | import { buildSchema } from "type-graphql"; 4 | import { UserResolver } from "./resolvers/UserResolver"; 5 | import { AccountResolver } from "./resolvers/AccountResolver"; 6 | import { TransactionResolver } from "./resolvers/TransactionResolver"; 7 | import cookieParser from "cookie-parser"; 8 | import { verify } from "jsonwebtoken"; 9 | import cors from "cors"; 10 | import { User } from "./entity/User"; 11 | import { createAccessToken, createRefreshToken } from "./utils/auth"; 12 | import { sendRefreshToken } from "./utils/sendRefreshToken"; 13 | import { createTypeOrmConnection } from "./utils/createTypeOrmConnection"; 14 | import { createConnection } from "typeorm"; 15 | import { CardResolver } from "./resolvers/CardResolver"; 16 | import "dotenv/config"; 17 | import "reflect-metadata"; 18 | 19 | (async () => { 20 | const app = express(); 21 | 22 | app.use(cookieParser()); 23 | app.use( 24 | cors({ 25 | origin: 26 | process.env.NODE_ENV === "production" 27 | ? "https://vigilant-goldwasser-9ac664.netlify.app" 28 | : "http://localhost:3000", 29 | credentials: true, 30 | }) 31 | ); 32 | 33 | app.get("/", (_req: Request, res: Response) => { 34 | res.send("🚀 Server is running"); 35 | }); 36 | 37 | app.post("/refresh_token", async (req: Request, res: Response) => { 38 | const token = req.cookies.jid; 39 | if (!token) { 40 | return res.send({ ok: false, accessToken: "" }); 41 | } 42 | 43 | let payload: any = null; 44 | 45 | try { 46 | payload = verify(token, process.env.REFRESH_TOKEN_SECRET!); 47 | } catch (err) { 48 | console.log(err); 49 | return res.send({ ok: false, accessToken: "" }); 50 | } 51 | 52 | // token is valid and can send back access token 53 | const user: User | undefined = await User.findOne({ id: payload.userId }); 54 | 55 | if (!user) { 56 | return res.send({ ok: false, accessToken: "" }); 57 | } 58 | 59 | if (user.tokenVersion !== payload.tokenVersion) { 60 | return res.send({ ok: true, accessToken: createAccessToken(user) }); 61 | } 62 | 63 | sendRefreshToken(res, createRefreshToken(user)); 64 | 65 | return res.send({ ok: true, accessToken: createAccessToken(user) }); 66 | }); 67 | 68 | process.env.NODE_ENV === "production" 69 | ? await createTypeOrmConnection() 70 | : await createConnection(); 71 | 72 | const appolloServer: ApolloServer = new ApolloServer({ 73 | schema: await buildSchema({ 74 | resolvers: [UserResolver, AccountResolver, TransactionResolver, CardResolver], 75 | }), 76 | introspection: true, 77 | playground: true, 78 | context: ({ req, res }) => ({ req, res }), 79 | }); 80 | 81 | appolloServer.applyMiddleware({ app, cors: false }); 82 | 83 | app.listen(process.env.PORT || 4000, () => { 84 | console.log(`🚀 Server ready at ${process.env.PORT || 4000}${appolloServer.graphqlPath}`); 85 | }); 86 | })(); 87 | -------------------------------------------------------------------------------- /client/src/components/Cards/AccountsCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent } from 'react'; 2 | import { IconButton, Typography, Divider, Button, ThemeProvider } from '@material-ui/core'; 3 | import NavigateNextIcon from '@material-ui/icons/NavigateNext'; 4 | import AddIcon from '@material-ui/icons/Add'; 5 | import { Title } from '../Typography/Title'; 6 | import { ColorScheme, theme } from '../../utils/theme'; 7 | import { useAccountsCardStyles, useNoAccountsCardStyles } from './styles/AccountsCard.style'; 8 | 9 | interface AccountsCardProps { 10 | svg: any | string; 11 | currencyIcon: string; 12 | fullCurrencyText: string; 13 | balance: number; 14 | iban: string; 15 | onAccountClicked(e: MouseEvent): void; 16 | } 17 | 18 | interface NoAccountCardProps { 19 | onCreateNewAccountClicked(e: MouseEvent): void; 20 | } 21 | 22 | export const AccountsCard: React.FC = ({ 23 | svg, 24 | currencyIcon, 25 | fullCurrencyText, 26 | balance, 27 | iban, 28 | onAccountClicked, 29 | }) => { 30 | const classes = useAccountsCardStyles(); 31 | 32 | return ( 33 | <> 34 |
35 |
{svg}
36 | 37 | <div> 38 | <IconButton style={{ color: ColorScheme.PRIMARY }} onClick={onAccountClicked}> 39 | <NavigateNextIcon fontSize="small" /> 40 | </IconButton> 41 | </div> 42 | </div> 43 | <Typography style={{ margin: '0 auto', marginTop: '24px' }} component="p" variant="h4"> 44 | {currencyIcon} 45 | {balance} 46 | </Typography> 47 | <Divider style={{ marginTop: 24 }} light /> 48 | <Typography 49 | style={{ 50 | marginTop: '14px', 51 | letterSpacing: 1, 52 | color: 'rgba(0, 0, 0, 0.3)', 53 | }} 54 | component="p" 55 | > 56 | {!!iban ? iban : 'XXXX APL0 0099 YYYY ZZZZ 78'} 57 | </Typography> 58 | </> 59 | ); 60 | }; 61 | 62 | export const NoAccountsCard: React.FC<NoAccountCardProps> = ({ onCreateNewAccountClicked }) => { 63 | const classes = useNoAccountsCardStyles(); 64 | return ( 65 | <div className={classes.root}> 66 | <ThemeProvider theme={theme}> 67 | <Button 68 | style={{ fontWeight: 'bold', textTransform: 'none', letterSpacing: 1 }} 69 | color="primary" 70 | variant="contained" 71 | startIcon={<AddIcon />} 72 | onClick={onCreateNewAccountClicked} 73 | > 74 | Create new account 75 | </Button> 76 | </ThemeProvider> 77 | </div> 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/react-hooks": "^3.1.3", 7 | "@fortawesome/fontawesome-svg-core": "^1.2.27", 8 | "@fortawesome/free-brands-svg-icons": "^5.12.1", 9 | "@fortawesome/free-solid-svg-icons": "^5.12.1", 10 | "@fortawesome/react-fontawesome": "^0.1.9", 11 | "@material-ui/core": "^4.9.5", 12 | "@material-ui/icons": "^4.9.1", 13 | "@material-ui/lab": "^4.0.0-alpha.45", 14 | "@testing-library/jest-dom": "^5.14.1", 15 | "@testing-library/react": "^12.1.2", 16 | "@testing-library/user-event": "^7.2.1", 17 | "@types/recharts": "^1.8.9", 18 | "apollo-boost": "^0.4.7", 19 | "apollo-cache-inmemory": "^1.6.5", 20 | "apollo-client": "^2.6.8", 21 | "apollo-link": "^1.2.13", 22 | "apollo-link-error": "^1.1.12", 23 | "apollo-link-http": "^1.5.16", 24 | "apollo-link-state": "^0.4.2", 25 | "apollo-link-token-refresh": "^0.2.7", 26 | "chart.js": "^2.9.4", 27 | "formik": "^2.1.4", 28 | "graphql": "^14.6.0", 29 | "graphql-tag": "^2.10.3", 30 | "jwt-decode": "^2.2.0", 31 | "moment": "^2.24.0", 32 | "react": "^16.12.0", 33 | "react-dom": "^16.12.0", 34 | "react-helmet": "^5.2.1", 35 | "react-jss": "^10.0.4", 36 | "react-router-dom": "^5.1.2", 37 | "react-scripts": "^4.0.3", 38 | "recharts": "^1.8.5", 39 | "typescript": "^3.7.5", 40 | "yup": "^0.28.2" 41 | }, 42 | "scripts": { 43 | "start": "react-scripts start", 44 | "build": "react-scripts build", 45 | "test": "react-scripts test", 46 | "eject": "react-scripts eject", 47 | "gen": "graphql-codegen --config codegen.yml" 48 | }, 49 | "eslintConfig": { 50 | "extends": "react-app" 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | }, 64 | "devDependencies": { 65 | "@graphql-codegen/cli": "^2.2.1", 66 | "@graphql-codegen/typescript": "^1.17.9", 67 | "@graphql-codegen/typescript-operations": "^1.17.8", 68 | "@graphql-codegen/typescript-react-apollo": "^3.1.6", 69 | "@types/graphql": "^14.5.0", 70 | "@types/hapi__joi": "^16.0.12", 71 | "@types/jest": "^27.0.2", 72 | "@types/jwt-decode": "^2.2.1", 73 | "@types/node": "^12.12.28", 74 | "@types/react": "^16.9.22", 75 | "@types/react-datepicker": "^2.11.0", 76 | "@types/react-dom": "^16.9.5", 77 | "@types/react-helmet": "^5.0.15", 78 | "@types/react-router-dom": "^5.1.3", 79 | "@types/yup": "^0.26.32" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/src/pages/Accounts/Transactions/Transactions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TransactionsQuery, Transaction } from '../../../generated/graphql'; 3 | import { useAccountStyles } from '../styles/Account.style'; 4 | import { Loading } from '../../../components/Loading/Loading'; 5 | import { TransactionCard } from '../../../components/Cards/TransactionCard'; 6 | import PaymentIcon from '@material-ui/icons/Payment'; 7 | import AccountBalanceIcon from '@material-ui/icons/AccountBalance'; 8 | import LocalAtmIcon from '@material-ui/icons/LocalAtm'; 9 | import ReceiptIcon from '@material-ui/icons/Receipt'; 10 | 11 | interface TransactionProps { 12 | account: TransactionsQuery | undefined; 13 | cardNumber: string | undefined; 14 | currencyIcon?: string; 15 | } 16 | 17 | export const Transactions: React.FC<TransactionProps> = ({ account, cardNumber, currencyIcon }) => { 18 | const classes = useAccountStyles(); 19 | 20 | if (!account) { 21 | return <Loading />; 22 | } 23 | 24 | return ( 25 | <div> 26 | <div className={classes.transactions}> 27 | <div className={classes.transactionsHeader}></div> 28 | <div className={classes.transactionCards}> 29 | {account.transactions.length > 0 && 30 | account.transactions.map((transaction: Transaction) => { 31 | let transactionIcon: any; 32 | 33 | switch (transaction.transactionType) { 34 | case 'payment': 35 | transactionIcon = <PaymentIcon />; 36 | break; 37 | case 'deposit': 38 | transactionIcon = <AccountBalanceIcon />; 39 | break; 40 | case 'withdrawal': 41 | transactionIcon = <LocalAtmIcon />; 42 | break; 43 | case 'invoice': 44 | transactionIcon = <ReceiptIcon />; 45 | break; 46 | } 47 | 48 | return ( 49 | <TransactionCard 50 | key={transaction.id} 51 | title={transaction.transactionType} 52 | time={new Date( 53 | Date.parse(transaction.date), 54 | ).toLocaleDateString()} 55 | card={cardNumber} 56 | amount={transaction.amount} 57 | currencyIcon={currencyIcon} 58 | transactionIcon={transactionIcon} 59 | /> 60 | ); 61 | })} 62 | </div> 63 | </div> 64 | </div> 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Apollobank 2 | 👍🎉 First off, thanks for taking the time to contribute! 🎉👍 3 | <p>I didn't expect this project to get a large amount of ⭐ because it is only a functional project for my personal practice.</p> 4 | <p>It is already getting over 150 ⭐ in here that's why I am going to make it open source so anyone can attribute it to this project.</p> 5 | 6 | I want to make contributing to this project as easy and transparent as possible, whether it's: 7 | 8 | - Reporting a bug 9 | - Discussing the current state of the code 10 | - Submitting a fix 11 | - Proposing new features 12 | - Becoming a maintainer 13 | 14 | ## We Develop with Github 15 | We use github to host code, to track issues and feature requests, as well as accept pull requests. 16 | 17 | ## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests 18 | Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests: 19 | 20 | 1. Fork the repo and create your branch from `main`. 21 | 2. If you've added code that should be tested, add tests. 22 | 3. If you've changed APIs, update the documentation. 23 | 4. Ensure the test suite passes. 24 | 5. Make sure your code lints. 25 | 6. Issue that pull request! 26 | 27 | ## Any contributions you make will be under the MIT Software License 28 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 29 | 30 | ## Report bugs using Github's [issues](https://github.com/edwardcdev/apollobank/issues) 31 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy! 32 | 33 | ## Write bug reports with detail, background, and sample code 34 | [This is an example](http://stackoverflow.com/q/12488905/180626) of a bug report, and I think it's not a bad model. Here's [another example from Craig Hockenberry](http://www.openradar.me/11905408), an app developer whom I greatly respect. 35 | 36 | **Great Bug Reports** tend to have: 37 | 38 | - A quick summary and/or background 39 | - Steps to reproduce 40 | - Be specific! 41 | - Give sample code if you can. 42 | - What you expected would happen 43 | - What actually happens 44 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 45 | 46 | People *love* thorough bug reports. I'm not even kidding. 47 | 48 | ## Use a Consistent Coding Style 49 | I'm again borrowing these from [Facebook's Guidelines](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) 50 | 51 | * 2 spaces for indentation rather than tabs 52 | * You can try running `npm run lint` for style unification 53 | 54 | ## License 55 | By contributing, you agree that your contributions will be licensed under its MIT License. 56 | 57 | ## References 58 | This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) 59 | -------------------------------------------------------------------------------- /client/src/components/Cards/TransactionCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Avatar, 4 | Card, 5 | CardHeader, 6 | Collapse, 7 | CardContent, 8 | IconButton, 9 | CardActions, 10 | ThemeProvider, 11 | } from '@material-ui/core'; 12 | import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; 13 | import { theme } from '../../utils/theme'; 14 | import { useTransactionCardStyles } from './styles/TransactionCard.style'; 15 | 16 | interface TransactionCardProps { 17 | title: string; 18 | amount: string; 19 | time: string; 20 | card?: string; 21 | transactionIcon?: any; 22 | currencyIcon?: string; 23 | } 24 | 25 | export const TransactionCard: React.FC<TransactionCardProps> = ({ 26 | title, 27 | time, 28 | amount, 29 | card, 30 | transactionIcon, 31 | currencyIcon, 32 | }) => { 33 | const classes = useTransactionCardStyles(); 34 | const [expanded, setExpanded] = useState(false); 35 | 36 | const handleExpandClick = () => { 37 | setExpanded(!expanded); 38 | }; 39 | 40 | return ( 41 | <div style={{ marginTop: 12 }}> 42 | <Card className={classes.root}> 43 | <CardHeader 44 | avatar={ 45 | <Avatar className={classes.avatar} aria-label="whatever"> 46 | {transactionIcon} 47 | </Avatar> 48 | } 49 | title={title} 50 | subheader={time} 51 | style={{ textAlign: 'left' }} 52 | /> 53 | <CardActions style={{ marginTop: '-40px' }}> 54 | <ThemeProvider theme={theme}> 55 | <IconButton 56 | style={{ marginLeft: 420 }} 57 | color="primary" 58 | onClick={handleExpandClick} 59 | aria-expanded={expanded} 60 | aria-label="show more" 61 | > 62 | <ExpandMoreIcon /> 63 | </IconButton> 64 | </ThemeProvider> 65 | </CardActions> 66 | <Collapse in={expanded} timeout="auto" unmountOnExit> 67 | <CardContent style={{ marginTop: '-24px' }}> 68 | <hr 69 | style={{ 70 | border: 'none', 71 | borderBottom: `1px solid black`, 72 | }} 73 | /> 74 | <div className={classes.expandedText} style={{ marginTop: 12 }}> 75 | Apollo card: <span style={{ color: 'black' }}>{card}</span> 76 | </div> 77 | <div className={classes.expandedText}> 78 | Amount:{' '} 79 | <span style={{ color: 'black' }}> 80 | {currencyIcon} 81 | {amount} 82 | </span> 83 | </div> 84 | </CardContent> 85 | </Collapse> 86 | </Card> 87 | </div> 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /server/src/resolvers/TransactionResolver.ts: -------------------------------------------------------------------------------- 1 | import { Account } from "./../entity/Account"; 2 | import { Transaction } from "./../entity/Transaction"; 3 | import { User } from "./../entity/User"; 4 | import { MyContext } from "./../MyContext"; 5 | import { isAuth } from "../middleware"; 6 | import { Resolver, Query, UseMiddleware, Ctx, Mutation, Arg, Float } from "type-graphql"; 7 | import faker from "faker"; 8 | 9 | @Resolver() 10 | export class TransactionResolver { 11 | /** 12 | * Query for returning all transactions for an authenticated users currency account 13 | * @param currency 14 | * @param param1 15 | */ 16 | @Query(() => [Transaction]) 17 | @UseMiddleware(isAuth) 18 | async transactions(@Arg("currency") currency: string, @Ctx() { payload }: MyContext) { 19 | if (!payload) { 20 | return null; 21 | } 22 | 23 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 24 | 25 | if (owner) { 26 | const account: Account | undefined = await Account.findOne({ 27 | where: { owner: owner, currency: currency }, 28 | }); 29 | 30 | if (account) { 31 | return Transaction.find({ where: { account: account } }); 32 | } 33 | } 34 | 35 | return null; 36 | } 37 | 38 | /** 39 | * Mutation for creating a new transaction 40 | * @param currency 41 | * @param param1 42 | */ 43 | @Mutation(() => Float) 44 | @UseMiddleware(isAuth) 45 | async createTransaction(@Arg("currency") currency: string, @Ctx() { payload }: MyContext) { 46 | if (!payload) { 47 | return false; 48 | } 49 | 50 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 51 | 52 | if (owner) { 53 | const account: Account | undefined = await Account.findOne({ 54 | where: { owner: owner, currency: currency }, 55 | }); 56 | 57 | if (account) { 58 | // Generate fake financial data using faker 59 | let transactionType: string = faker.finance.transactionType(); 60 | let amount: number = parseInt(faker.finance.amount()); 61 | let date: Date = faker.date.recent(31); 62 | let balance: number = account.balance; 63 | 64 | if (balance <= 0) { 65 | throw new Error("You do not have the sufficient funds."); 66 | } 67 | 68 | // Update account balance depending on the transaction type faker generates 69 | switch (transactionType) { 70 | case "withdrawal": 71 | balance -= amount; 72 | break; 73 | case "deposit": 74 | balance += amount; 75 | break; 76 | case "payment": 77 | balance -= amount; 78 | break; 79 | case "invoice": 80 | balance -= amount; 81 | break; 82 | } 83 | 84 | try { 85 | await Transaction.insert({ 86 | account, 87 | transactionType: transactionType, 88 | date: date, 89 | amount: amount.toString(), 90 | }); 91 | await Account.update( 92 | { 93 | id: account.id, 94 | }, 95 | { balance: balance } 96 | ); 97 | } catch (err) { 98 | console.log(err); 99 | return null; 100 | } 101 | } 102 | } 103 | 104 | // In order to update the total account balance, return the above updated Accounts balance 105 | const updatedAccount = await Account.findOne({ where: { owner: owner, currency: currency } }); 106 | 107 | if (updatedAccount) { 108 | return updatedAccount.balance; 109 | } 110 | 111 | return null; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import { ApolloClient } from 'apollo-client'; 5 | import { InMemoryCache } from 'apollo-cache-inmemory'; 6 | import { HttpLink } from 'apollo-link-http'; 7 | import { onError } from 'apollo-link-error'; 8 | import { ApolloLink, Observable } from 'apollo-link'; 9 | import { ApolloProvider } from '@apollo/react-hooks'; 10 | import { getAccessToken, setAccessToken } from './utils/accessToken'; 11 | import { TokenRefreshLink } from 'apollo-link-token-refresh'; 12 | import jwtDecode from 'jwt-decode'; 13 | import { App } from './App'; 14 | 15 | // Setup Apollo Client manually without Apollo Boost 16 | // https://www.apollographql.com/docs/react/migrating/boost-migration/ 17 | 18 | const cache = new InMemoryCache({}); 19 | 20 | const requestLink = new ApolloLink( 21 | (operation, forward) => 22 | new Observable(observer => { 23 | let handle: any; 24 | Promise.resolve(operation) 25 | .then(operation => { 26 | const accessToken = getAccessToken(); 27 | operation.setContext({ 28 | headers: { 29 | authorization: accessToken ? `Bearer ${accessToken}` : '', 30 | }, 31 | }); 32 | }) 33 | .then(() => { 34 | handle = forward(operation).subscribe({ 35 | next: observer.next.bind(observer), 36 | error: observer.error.bind(observer), 37 | complete: observer.complete.bind(observer), 38 | }); 39 | }) 40 | .catch(observer.error.bind(observer)); 41 | 42 | return () => { 43 | if (handle) handle.unsubscribe(); 44 | }; 45 | }), 46 | ); 47 | 48 | const client = new ApolloClient({ 49 | link: ApolloLink.from([ 50 | new TokenRefreshLink({ 51 | accessTokenField: 'accessToken', 52 | isTokenValidOrUndefined: () => { 53 | const token = getAccessToken(); 54 | 55 | if (!token) { 56 | return true; 57 | } 58 | 59 | try { 60 | const { exp } = jwtDecode(token); 61 | if (Date.now() >= exp * 1000) { 62 | return false; 63 | } else { 64 | return true; 65 | } 66 | } catch { 67 | return false; 68 | } 69 | }, 70 | fetchAccessToken: () => { 71 | return fetch((process.env.REACT_APP_SERVER_URL as string) + '/refresh_token', { 72 | method: 'POST', 73 | credentials: 'include', 74 | }); 75 | }, 76 | handleFetch: accessToken => { 77 | setAccessToken(accessToken); 78 | }, 79 | handleError: err => { 80 | console.warn('Your refresh token is invalid. Try to relogin'); 81 | console.error(err); 82 | }, 83 | }), 84 | onError(({ graphQLErrors, networkError }) => { 85 | console.log(graphQLErrors); 86 | console.log(networkError); 87 | }), 88 | requestLink, 89 | new HttpLink({ 90 | uri: process.env.REACT_APP_SERVER_URL + '/graphql', 91 | credentials: 'include', 92 | }), 93 | ]), 94 | cache, 95 | }); 96 | 97 | ReactDOM.render( 98 | <ApolloProvider client={client}> 99 | <App /> 100 | </ApolloProvider>, 101 | document.getElementById('root'), 102 | ); 103 | -------------------------------------------------------------------------------- /client/src/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType, useState } from 'react'; 2 | import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom'; 3 | import { Helmet } from 'react-helmet'; 4 | import { Home } from './pages/Home'; 5 | import { Bye } from './pages/Bye'; 6 | import { Register } from './pages/Register/Register'; 7 | import { Login } from './pages/Login/Login'; 8 | import { getAccessToken } from './utils/accessToken'; 9 | import { Account } from './pages/Accounts/Account'; 10 | import { Toolbar } from './components/Toolbar/Toolbar'; 11 | import { SideDrawer } from './components/SideDrawer/SideDrawer'; 12 | import { Backdrop } from './components/Backdrop/Backdrop'; 13 | import { ColorScheme } from './utils/theme'; 14 | import { Settings } from './pages/Settings/Settings'; 15 | import { Dashboard } from './pages/Dashboard/Dashboard'; 16 | 17 | interface AuthenticatedRouteProps { 18 | exact?: boolean; 19 | path: string; 20 | component: ComponentType<any>; 21 | } 22 | 23 | // If an access token is identified, allow the user to access the route, otherwise route to the login page 24 | const AuthenticatedRoute = ({ component: Component, ...rest }: AuthenticatedRouteProps) => ( 25 | <Route 26 | {...rest} 27 | render={props => 28 | getAccessToken() ? <Component {...props} /> : <Redirect to={{ pathname: '/login' }} /> 29 | } 30 | /> 31 | ); 32 | 33 | // If an access token is identified, push the user to the dashboard, otherwise route to the specified component 34 | const LoggedInRoute = ({ component: Component, ...rest }: AuthenticatedRouteProps) => ( 35 | <Route 36 | {...rest} 37 | render={props => 38 | getAccessToken() ? ( 39 | <Redirect to={{ pathname: '/dashboard' }} /> 40 | ) : ( 41 | <Component {...props} /> 42 | ) 43 | } 44 | /> 45 | ); 46 | 47 | export const Routes: React.FC = () => { 48 | const [sideDrawerOpen, setSideDrawerOpen] = useState(false); 49 | 50 | let backdrop: any; 51 | 52 | const drawerToggleClickHandler = () => { 53 | setSideDrawerOpen(true); 54 | }; 55 | 56 | const backdropClickHandler = () => { 57 | setSideDrawerOpen(false); 58 | }; 59 | 60 | if (sideDrawerOpen) { 61 | backdrop = <Backdrop click={backdropClickHandler} />; 62 | } 63 | 64 | return ( 65 | <> 66 | <Helmet> 67 | <style>{`body { background-color: ${ColorScheme.WHITE}; }`}</style> 68 | </Helmet> 69 | <BrowserRouter> 70 | <div style={{ height: '100%' }}> 71 | <Toolbar drawerClickHandler={drawerToggleClickHandler} /> 72 | <SideDrawer show={sideDrawerOpen} /> 73 | {backdrop} 74 | <main style={{ marginTop: 24 }}> 75 | <Switch> 76 | <Route exact path="/" component={Home} /> 77 | <LoggedInRoute exact path="/register" component={Register} /> 78 | <LoggedInRoute exact path="/login" component={Login} /> 79 | <AuthenticatedRoute exact path="/dashboard" component={Dashboard} /> 80 | <AuthenticatedRoute exact path="/accounts/:id" component={Account} /> 81 | <AuthenticatedRoute exact path="/settings" component={Settings} /> 82 | <Route exact path="/bye" component={Bye} /> 83 | <Route 84 | path="/" 85 | render={() => ( 86 | <div 87 | style={{ 88 | display: 'flex', 89 | justifyContent: 'center', 90 | marginTop: 12, 91 | }} 92 | > 93 | 404 Not Found 94 | </div> 95 | )} 96 | /> 97 | </Switch> 98 | </main> 99 | </div> 100 | </BrowserRouter> 101 | </> 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /client/src/components/Toolbar/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, MouseEvent } from 'react'; 2 | import { useToolbarStyles } from './Toolbar.style'; 3 | import { DrawerToggleButton } from '../SideDrawer/DrawerToggleButton'; 4 | import { 5 | useMeQuery, 6 | useLogoutMutation, 7 | MeQueryResult, 8 | LogoutMutation, 9 | LogoutMutationVariables, 10 | } from '../../generated/graphql'; 11 | import { useHistory } from 'react-router-dom'; 12 | import { setAccessToken } from '../../utils/accessToken'; 13 | import { MutationTuple } from '@apollo/react-hooks'; 14 | 15 | interface ToolbarProps { 16 | drawerClickHandler(): void; 17 | } 18 | 19 | const navigationItems: string[] = ['Dashboard', 'Settings']; 20 | 21 | export const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => { 22 | // GraphQL Mutations 23 | const [logout, { client }]: MutationTuple< 24 | LogoutMutation, 25 | LogoutMutationVariables 26 | > = useLogoutMutation(); 27 | 28 | // GraphQL Queries 29 | const { data, loading }: MeQueryResult = useMeQuery(); 30 | 31 | // State 32 | const [showAuthUserButtons, setShowAuthUserButtons] = useState<boolean>(false); 33 | 34 | const history = useHistory(); 35 | 36 | const classes = useToolbarStyles(); 37 | 38 | // When the component mounts, if the user exists render the authenticated buttons, otherwise the non-authenticated buttons 39 | // Authenticated user buttons -> Dashboard, Settings, Logout 40 | // Non-authenticated user buttons -> Login, Sign Up 41 | useEffect(() => { 42 | if (!loading && data && data.me) { 43 | setShowAuthUserButtons(true); 44 | } else { 45 | setShowAuthUserButtons(false); 46 | } 47 | }, [data, loading]); 48 | 49 | const renderAuthUserButtons = (): JSX.Element => { 50 | return ( 51 | <> 52 | {navigationItems.map((item: string) => { 53 | let routeTo: string = '/'; 54 | 55 | switch (item) { 56 | case 'Dashboard': 57 | routeTo = '/dashboard'; 58 | break; 59 | case 'Settings': 60 | routeTo = '/settings'; 61 | break; 62 | } 63 | return ( 64 | <li key={item}> 65 | <a href={routeTo}>{item}</a> 66 | </li> 67 | ); 68 | })} 69 | <button 70 | className={classes.navButton} 71 | onClick={async e => { 72 | e.preventDefault(); 73 | await logout().then(() => history.push('/')); 74 | setAccessToken(''); 75 | await client!.resetStore(); 76 | }} 77 | > 78 | Logout 79 | </button> 80 | </> 81 | ); 82 | }; 83 | 84 | const renderNonAuthUserButtons = (): JSX.Element => { 85 | return ( 86 | <> 87 | <button 88 | className={classes.navButton} 89 | onClick={e => { 90 | e.preventDefault(); 91 | history.push('/login'); 92 | }} 93 | > 94 | Login 95 | </button> 96 | <button 97 | className={classes.navButton} 98 | onClick={(e: MouseEvent<HTMLButtonElement>) => { 99 | e.preventDefault(); 100 | history.push('/register'); 101 | }} 102 | > 103 | Sign Up 104 | </button> 105 | </> 106 | ); 107 | }; 108 | 109 | return ( 110 | <header className={classes.toolbar}> 111 | <nav className={classes.navigation}> 112 | <div className={classes.toggleButton}> 113 | <DrawerToggleButton click={props.drawerClickHandler} /> 114 | </div> 115 | <div className={classes.logo}> 116 | <a href="/"> 117 | <span role="img" aria-label="logo"> 118 | 🚀 119 | </span> 120 | </a> 121 | </div> 122 | <div className={classes.spacer} /> 123 | <div className={classes.navigationItems}> 124 | <ul> 125 | {!!showAuthUserButtons 126 | ? renderAuthUserButtons() 127 | : renderNonAuthUserButtons()} 128 | </ul> 129 | </div> 130 | </nav> 131 | </header> 132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /client/src/components/SideDrawer/SideDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, MouseEvent } from 'react'; 2 | import { useSideDrawerStyles } from './SideDrawer.style'; 3 | import { 4 | useMeQuery, 5 | useLogoutMutation, 6 | MeQueryResult, 7 | LogoutMutationVariables, 8 | LogoutMutation, 9 | } from '../../generated/graphql'; 10 | import { useHistory } from 'react-router-dom'; 11 | import { setAccessToken } from '../../utils/accessToken'; 12 | import { MutationTuple } from '@apollo/react-hooks'; 13 | 14 | const authUserNavigationItems: string[] = ['Dashboard', 'Settings', 'Logout']; 15 | const nonAuthUserNavigationItems: string[] = ['Login', 'Sign Up']; 16 | 17 | interface SideDrawerProps { 18 | show: boolean; 19 | } 20 | 21 | export const SideDrawer: React.FC<SideDrawerProps> = (props: SideDrawerProps) => { 22 | // GraphQL Mutations 23 | const [logout, { client }]: MutationTuple< 24 | LogoutMutation, 25 | LogoutMutationVariables 26 | > = useLogoutMutation(); 27 | 28 | // GraphQL Queries 29 | const { data, loading }: MeQueryResult = useMeQuery(); 30 | 31 | // State 32 | const [showAuthUserNavigationItems, setShowAuthUserNavigationItems] = useState<boolean>(false); 33 | 34 | const history = useHistory(); 35 | const classes = useSideDrawerStyles(); 36 | 37 | // When the component mounts, if the user exists render the authenticated buttons, otherwise the non-authenticated buttons 38 | // Authenticated user buttons -> Dashboard, Settings, Logout 39 | // Non-authenticated user buttons -> Login, Sign Up 40 | useEffect(() => { 41 | if (!loading && data && data.me) { 42 | setShowAuthUserNavigationItems(true); 43 | } else { 44 | setShowAuthUserNavigationItems(false); 45 | } 46 | }, [data, loading]); 47 | 48 | let drawerClasses: string = classes.siderDrawer; 49 | 50 | if (props.show) { 51 | drawerClasses = classes.siderDrawer + ' ' + classes.open; 52 | } 53 | 54 | const renderAuthUserNavigationItems = (): JSX.Element => { 55 | return ( 56 | <> 57 | {authUserNavigationItems.map(item => { 58 | let routeTo: string = '/'; 59 | let logOutClicked: boolean = false; 60 | 61 | switch (item) { 62 | case 'Dashboard': 63 | routeTo = '/Dashboard'; 64 | break; 65 | case 'Settings': 66 | routeTo = '/settings'; 67 | break; 68 | case 'Logout': 69 | logOutClicked = true; 70 | break; 71 | } 72 | 73 | return ( 74 | <li key={item}> 75 | <a 76 | href="/whatevs" 77 | onClick={async (e: MouseEvent<Element, globalThis.MouseEvent>) => { 78 | e.preventDefault(); 79 | if (logOutClicked) { 80 | await logout().then(() => history.push('/')); 81 | setAccessToken(''); 82 | await client!.resetStore(); 83 | } 84 | history.push(routeTo); 85 | }} 86 | > 87 | {item} 88 | </a> 89 | </li> 90 | ); 91 | })} 92 | </> 93 | ); 94 | }; 95 | 96 | const renderNonAuthUserNavigationItems = (): JSX.Element => { 97 | return ( 98 | <> 99 | {nonAuthUserNavigationItems.map((item: string) => { 100 | let routeTo: string = '/'; 101 | 102 | switch (item) { 103 | case 'Login': 104 | routeTo = '/login'; 105 | break; 106 | case 'Sign Up': 107 | routeTo = '/register'; 108 | break; 109 | } 110 | 111 | return ( 112 | <li key={item}> 113 | <a 114 | href="/whatevs" 115 | onClick={( 116 | e: MouseEvent<HTMLAnchorElement, globalThis.MouseEvent>, 117 | ) => { 118 | e.preventDefault(); 119 | history.push(routeTo); 120 | }} 121 | > 122 | {item} 123 | </a> 124 | </li> 125 | ); 126 | })} 127 | </> 128 | ); 129 | }; 130 | 131 | return ( 132 | <nav className={drawerClasses}> 133 | <ul> 134 | {!!showAuthUserNavigationItems 135 | ? renderAuthUserNavigationItems() 136 | : renderNonAuthUserNavigationItems()} 137 | </ul> 138 | </nav> 139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /client/src/pages/Login/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import { 4 | useLoginMutation, 5 | MeDocument, 6 | MeQuery, 7 | LoginMutationVariables, 8 | LoginMutation, 9 | } from '../../generated/graphql'; 10 | import { setAccessToken } from '../../utils/accessToken'; 11 | import { Formik, Form } from 'formik'; 12 | import { FormTextField } from '../../components/Forms/FormTextField'; 13 | import { Button, ThemeProvider } from '@material-ui/core'; 14 | import { theme, ColorScheme } from '../../utils/theme'; 15 | import { loginValidationSchema } from '../../schemas/loginValidationSchema'; 16 | import { ErrorMessage } from '../../components/Alerts/AlertMessage'; 17 | import { useLoginStyles } from './Login.style'; 18 | import { MutationTuple } from '@apollo/react-hooks'; 19 | import { ExecutionResult } from 'graphql'; 20 | 21 | export const Login: React.FC<RouteComponentProps> = ({ history }) => { 22 | // GraphQL Mutations 23 | const [login]: MutationTuple<LoginMutation, LoginMutationVariables> = useLoginMutation(); 24 | 25 | // State 26 | const [errorMessage, setErrorMessage] = useState<string>(''); 27 | 28 | const classes = useLoginStyles(); 29 | 30 | return ( 31 | <div className={classes.root}> 32 | <div> 33 | <h1 className={classes.headerText}>Login</h1> 34 | </div> 35 | {errorMessage.length > 0 && ( 36 | <div style={{ display: 'flex', justifyContent: 'center' }}> 37 | <ErrorMessage message={errorMessage} /> 38 | </div> 39 | )} 40 | <Formik 41 | initialValues={{ email: '', password: '' }} 42 | validationSchema={loginValidationSchema} 43 | onSubmit={async (data, { setSubmitting, resetForm }) => { 44 | setSubmitting(true); 45 | 46 | // On login button click, call the login mutation 47 | try { 48 | const response: ExecutionResult<LoginMutation> = await login({ 49 | variables: { 50 | email: data.email, 51 | password: data.password, 52 | }, 53 | update: (store, { data }) => { 54 | if (!data) { 55 | return null; 56 | } 57 | store.writeQuery<MeQuery>({ 58 | query: MeDocument, 59 | data: { 60 | me: data.login.user, 61 | }, 62 | }); 63 | }, 64 | }); 65 | 66 | // If the login was successful, provide the user with an access token that can be used for 67 | // routes which require authentication 68 | // Route the user to the dashboard 69 | if (response && response.data) { 70 | setAccessToken(response.data.login.accessToken); 71 | history.push('/dashboard'); 72 | setSubmitting(false); 73 | resetForm(); 74 | } 75 | } catch (error) { 76 | const errorMessage: string = error.message.split(':')[1]; 77 | setErrorMessage(errorMessage); 78 | setSubmitting(false); 79 | } 80 | }} 81 | > 82 | {({ isSubmitting }) => ( 83 | <div> 84 | <Form> 85 | <div> 86 | <FormTextField 87 | className={classes.formField} 88 | name="email" 89 | placeholder="Email" 90 | type="input" 91 | /> 92 | <FormTextField 93 | className={classes.formField} 94 | name="password" 95 | placeholder="Password" 96 | type="password" 97 | /> 98 | </div> 99 | <div className={classes.formButton}> 100 | <ThemeProvider theme={theme}> 101 | <Button 102 | className={classes.formButton} 103 | disabled={isSubmitting} 104 | variant="contained" 105 | color="secondary" 106 | type="submit" 107 | > 108 | Login 109 | </Button> 110 | </ThemeProvider> 111 | </div> 112 | <div className={classes.registerText}> 113 | <p> 114 | Don't have an account?{' '} 115 | <a 116 | href="/register" 117 | style={{ 118 | textDecoration: 'none', 119 | color: ColorScheme.SECONDARY, 120 | }} 121 | > 122 | Sign up here. 123 | </a> 124 | </p> 125 | </div> 126 | </Form> 127 | </div> 128 | )} 129 | </Formik> 130 | </div> 131 | ); 132 | }; 133 | -------------------------------------------------------------------------------- /server/src/resolvers/UserResolver.ts: -------------------------------------------------------------------------------- 1 | import { registerSchema, loginSchema, changePasswordSchema } from "./../utils/validation"; 2 | import { createRefreshToken, createAccessToken } from "../utils/auth"; 3 | import { 4 | Resolver, 5 | Query, 6 | Mutation, 7 | Arg, 8 | Field, 9 | ObjectType, 10 | Ctx, 11 | UseMiddleware, 12 | Int, 13 | } from "type-graphql"; 14 | import { hash, compare } from "bcryptjs"; 15 | import { User } from "../entity/User"; 16 | import { MyContext } from "../MyContext"; 17 | import { isAuth } from "../middleware"; 18 | import { sendRefreshToken } from "../utils/sendRefreshToken"; 19 | import { ErrorMessages } from "../utils/messages"; 20 | import { getConnection } from "typeorm"; 21 | import { verify } from "jsonwebtoken"; 22 | 23 | @ObjectType() 24 | class LoginResponse { 25 | @Field() 26 | accessToken: string; 27 | 28 | @Field(() => User) 29 | user: User; 30 | } 31 | 32 | @Resolver() 33 | export class UserResolver { 34 | /** 35 | * Query to return the current user 36 | * Must first check that the user contains the secret token in the authorization header 37 | * If the token is found, use jwt to verify the token 38 | * If verified, return the user 39 | * @param context 40 | */ 41 | @Query(() => User, { nullable: true }) 42 | me(@Ctx() context: MyContext) { 43 | const authorization: string | undefined = context.req.headers["authorization"]; 44 | 45 | if (!authorization) { 46 | return null; 47 | } 48 | 49 | try { 50 | const token: string = authorization.split(" ")[1]; 51 | const payload: any = verify(token, process.env.ACCESS_TOKEN_SECRET!); 52 | context.payload = payload as any; 53 | return User.findOne(payload.userId); 54 | } catch (err) { 55 | console.log(err); 56 | return null; 57 | } 58 | } 59 | 60 | /** 61 | * Mutation to logout the user 62 | * Remove the refresh token once logged out 63 | * @param param0 64 | */ 65 | @Mutation(() => Boolean) 66 | async logout(@Ctx() { res }: MyContext) { 67 | sendRefreshToken(res, ""); 68 | return true; 69 | } 70 | 71 | /** 72 | * Mutation to revoke the refresh token for a user 73 | * @param userId 74 | */ 75 | @Mutation(() => Boolean) 76 | async revokeRefreshTokensForUser(@Arg("userId", () => Int) userId: number) { 77 | await getConnection().getRepository(User).increment({ id: userId }, "tokenVersion", 1); 78 | 79 | return true; 80 | } 81 | 82 | /** 83 | * Mutation to login 84 | * Must use the bcryptjs compare function to verify the users hashed password 85 | * If verified, send a refresh token, create an access token and login in the user 86 | * @param email 87 | * @param password 88 | * @param param2 89 | */ 90 | @Mutation(() => LoginResponse) 91 | async login( 92 | @Arg("email") email: string, 93 | @Arg("password") password: string, 94 | @Ctx() { res }: MyContext 95 | ): Promise<LoginResponse> { 96 | // Server side validation for login using Joi 97 | try { 98 | await loginSchema.validateAsync({ email: email, password: password }); 99 | } catch (error) { 100 | throw new Error("Something went wrong."); 101 | } 102 | 103 | const user: User | undefined = await User.findOne({ where: { email } }); 104 | 105 | if (!user) { 106 | throw new Error(ErrorMessages.LOGIN); 107 | } 108 | 109 | const valid: boolean = await compare(password, user.password); 110 | 111 | if (!valid) { 112 | throw new Error(ErrorMessages.PASSWORD); 113 | } 114 | 115 | // login successful 116 | sendRefreshToken(res, createRefreshToken(user)); 117 | 118 | return { 119 | accessToken: createAccessToken(user), 120 | user, 121 | }; 122 | } 123 | 124 | /** 125 | * Mutation to register a new user 126 | * Use bcryptjs to hash the password 127 | * @param email 128 | * @param password 129 | * @param firstName 130 | * @param lastName 131 | * @param dateOfBirth 132 | * @param streetAddress 133 | * @param postCode 134 | * @param city 135 | * @param country 136 | */ 137 | @Mutation(() => Boolean) 138 | async register( 139 | @Arg("email") email: string, 140 | @Arg("password") password: string, 141 | @Arg("firsName") firstName: string, 142 | @Arg("lastName") lastName: string, 143 | @Arg("dateOfBirth") dateOfBirth: string, 144 | @Arg("streetAddress") streetAddress: string, 145 | @Arg("postCode") postCode: string, 146 | @Arg("city") city: string, 147 | @Arg("country") country: string 148 | ) { 149 | // Server side validation for registering using Joi 150 | try { 151 | await registerSchema.validateAsync({ 152 | email: email, 153 | password: password, 154 | dateOfBirth: dateOfBirth, 155 | }); 156 | } catch (error) { 157 | console.log(error); 158 | return false; 159 | } 160 | 161 | const hashedPassword: string = await hash(password, 12); 162 | 163 | try { 164 | await User.insert({ 165 | email, 166 | password: hashedPassword, 167 | firstName, 168 | lastName, 169 | dateOfBirth, 170 | streetAddress, 171 | postCode, 172 | city, 173 | country, 174 | }); 175 | } catch (err) { 176 | console.log(err); 177 | return false; 178 | } 179 | 180 | return true; 181 | } 182 | 183 | /** 184 | * Mutation to update a users existing password 185 | * Use the compare function provided by bcryptjs to verify that the old password typed by a user is the existing password 186 | * If this is the case, hash the new password and update the password in the database 187 | * @param oldPassword 188 | * @param newPassword 189 | * @param param2 190 | */ 191 | @Mutation(() => Boolean) 192 | @UseMiddleware(isAuth) 193 | async updatePassword( 194 | @Arg("oldPassword") oldPassword: string, 195 | @Arg("newPassword") newPassword: string, 196 | @Ctx() { payload }: MyContext 197 | ) { 198 | if (!payload) { 199 | return false; 200 | } 201 | 202 | // Server side validation for changing password 203 | try { 204 | await changePasswordSchema.validateAsync({ 205 | oldPassword: oldPassword, 206 | newPassword: newPassword, 207 | }); 208 | } catch (error) { 209 | console.log(error); 210 | return false; 211 | } 212 | 213 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 214 | 215 | if (owner) { 216 | const valid = await compare(oldPassword, owner.password); 217 | 218 | if (valid) { 219 | const updatedPassword: string = await hash(newPassword, 12); 220 | 221 | try { 222 | await User.update( 223 | { 224 | id: owner.id, 225 | }, 226 | { 227 | password: updatedPassword, 228 | } 229 | ); 230 | } catch (err) { 231 | console.log(err); 232 | return false; 233 | } 234 | } else { 235 | throw new Error(ErrorMessages.UPDATE_PASSWORD); 236 | } 237 | } 238 | return true; 239 | } 240 | 241 | @Mutation(() => Boolean) 242 | @UseMiddleware(isAuth) 243 | async destroyAccount(@Ctx() { payload }: MyContext) { 244 | if (!payload) { 245 | return false; 246 | } 247 | 248 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 249 | 250 | if (owner) { 251 | try { 252 | await User.delete({ 253 | id: owner.id, 254 | }); 255 | } catch (error) { 256 | throw new Error(ErrorMessages.DELETE_ACCOUNT); 257 | } 258 | } 259 | return true; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /client/build/static/js/runtime-main.844668cf.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../webpack/bootstrap"],"names":["webpackJsonpCallback","data","moduleId","chunkId","chunkIds","moreModules","executeModules","i","resolves","length","Object","prototype","hasOwnProperty","call","installedChunks","push","modules","parentJsonpFunction","shift","deferredModules","apply","checkDeferredModules","result","deferredModule","fulfilled","j","depId","splice","__webpack_require__","s","installedModules","1","exports","module","l","m","c","d","name","getter","o","defineProperty","enumerable","get","r","Symbol","toStringTag","value","t","mode","__esModule","ns","create","key","bind","n","object","property","p","jsonpArray","this","oldJsonpFunction","slice"],"mappings":"aACE,SAASA,EAAqBC,GAQ7B,IAPA,IAMIC,EAAUC,EANVC,EAAWH,EAAK,GAChBI,EAAcJ,EAAK,GACnBK,EAAiBL,EAAK,GAIHM,EAAI,EAAGC,EAAW,GACpCD,EAAIH,EAASK,OAAQF,IACzBJ,EAAUC,EAASG,GAChBG,OAAOC,UAAUC,eAAeC,KAAKC,EAAiBX,IAAYW,EAAgBX,IACpFK,EAASO,KAAKD,EAAgBX,GAAS,IAExCW,EAAgBX,GAAW,EAE5B,IAAID,KAAYG,EACZK,OAAOC,UAAUC,eAAeC,KAAKR,EAAaH,KACpDc,EAAQd,GAAYG,EAAYH,IAKlC,IAFGe,GAAqBA,EAAoBhB,GAEtCO,EAASC,QACdD,EAASU,OAATV,GAOD,OAHAW,EAAgBJ,KAAKK,MAAMD,EAAiBb,GAAkB,IAGvDe,IAER,SAASA,IAER,IADA,IAAIC,EACIf,EAAI,EAAGA,EAAIY,EAAgBV,OAAQF,IAAK,CAG/C,IAFA,IAAIgB,EAAiBJ,EAAgBZ,GACjCiB,GAAY,EACRC,EAAI,EAAGA,EAAIF,EAAed,OAAQgB,IAAK,CAC9C,IAAIC,EAAQH,EAAeE,GACG,IAA3BX,EAAgBY,KAAcF,GAAY,GAE3CA,IACFL,EAAgBQ,OAAOpB,IAAK,GAC5Be,EAASM,EAAoBA,EAAoBC,EAAIN,EAAe,KAItE,OAAOD,EAIR,IAAIQ,EAAmB,GAKnBhB,EAAkB,CACrBiB,EAAG,GAGAZ,EAAkB,GAGtB,SAASS,EAAoB1B,GAG5B,GAAG4B,EAAiB5B,GACnB,OAAO4B,EAAiB5B,GAAU8B,QAGnC,IAAIC,EAASH,EAAiB5B,GAAY,CACzCK,EAAGL,EACHgC,GAAG,EACHF,QAAS,IAUV,OANAhB,EAAQd,GAAUW,KAAKoB,EAAOD,QAASC,EAAQA,EAAOD,QAASJ,GAG/DK,EAAOC,GAAI,EAGJD,EAAOD,QAKfJ,EAAoBO,EAAInB,EAGxBY,EAAoBQ,EAAIN,EAGxBF,EAAoBS,EAAI,SAASL,EAASM,EAAMC,GAC3CX,EAAoBY,EAAER,EAASM,IAClC5B,OAAO+B,eAAeT,EAASM,EAAM,CAAEI,YAAY,EAAMC,IAAKJ,KAKhEX,EAAoBgB,EAAI,SAASZ,GACX,qBAAXa,QAA0BA,OAAOC,aAC1CpC,OAAO+B,eAAeT,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DrC,OAAO+B,eAAeT,EAAS,aAAc,CAAEe,OAAO,KAQvDnB,EAAoBoB,EAAI,SAASD,EAAOE,GAEvC,GADU,EAAPA,IAAUF,EAAQnB,EAAoBmB,IAC/B,EAAPE,EAAU,OAAOF,EACpB,GAAW,EAAPE,GAA8B,kBAAVF,GAAsBA,GAASA,EAAMG,WAAY,OAAOH,EAChF,IAAII,EAAKzC,OAAO0C,OAAO,MAGvB,GAFAxB,EAAoBgB,EAAEO,GACtBzC,OAAO+B,eAAeU,EAAI,UAAW,CAAET,YAAY,EAAMK,MAAOA,IACtD,EAAPE,GAA4B,iBAATF,EAAmB,IAAI,IAAIM,KAAON,EAAOnB,EAAoBS,EAAEc,EAAIE,EAAK,SAASA,GAAO,OAAON,EAAMM,IAAQC,KAAK,KAAMD,IAC9I,OAAOF,GAIRvB,EAAoB2B,EAAI,SAAStB,GAChC,IAAIM,EAASN,GAAUA,EAAOiB,WAC7B,WAAwB,OAAOjB,EAAgB,SAC/C,WAA8B,OAAOA,GAEtC,OADAL,EAAoBS,EAAEE,EAAQ,IAAKA,GAC5BA,GAIRX,EAAoBY,EAAI,SAASgB,EAAQC,GAAY,OAAO/C,OAAOC,UAAUC,eAAeC,KAAK2C,EAAQC,IAGzG7B,EAAoB8B,EAAI,IAExB,IAAIC,EAAaC,KAAyB,mBAAIA,KAAyB,oBAAK,GACxEC,EAAmBF,EAAW5C,KAAKuC,KAAKK,GAC5CA,EAAW5C,KAAOf,EAClB2D,EAAaA,EAAWG,QACxB,IAAI,IAAIvD,EAAI,EAAGA,EAAIoD,EAAWlD,OAAQF,IAAKP,EAAqB2D,EAAWpD,IAC3E,IAAIU,EAAsB4C,EAI1BxC,I","file":"static/js/runtime-main.844668cf.js","sourcesContent":[" \t// install a JSONP callback for chunk loading\n \tfunction webpackJsonpCallback(data) {\n \t\tvar chunkIds = data[0];\n \t\tvar moreModules = data[1];\n \t\tvar executeModules = data[2];\n\n \t\t// add \"moreModules\" to the modules object,\n \t\t// then flag all \"chunkIds\" as loaded and fire callback\n \t\tvar moduleId, chunkId, i = 0, resolves = [];\n \t\tfor(;i < chunkIds.length; i++) {\n \t\t\tchunkId = chunkIds[i];\n \t\t\tif(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {\n \t\t\t\tresolves.push(installedChunks[chunkId][0]);\n \t\t\t}\n \t\t\tinstalledChunks[chunkId] = 0;\n \t\t}\n \t\tfor(moduleId in moreModules) {\n \t\t\tif(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {\n \t\t\t\tmodules[moduleId] = moreModules[moduleId];\n \t\t\t}\n \t\t}\n \t\tif(parentJsonpFunction) parentJsonpFunction(data);\n\n \t\twhile(resolves.length) {\n \t\t\tresolves.shift()();\n \t\t}\n\n \t\t// add entry modules from loaded chunk to deferred list\n \t\tdeferredModules.push.apply(deferredModules, executeModules || []);\n\n \t\t// run deferred modules when all chunks ready\n \t\treturn checkDeferredModules();\n \t};\n \tfunction checkDeferredModules() {\n \t\tvar result;\n \t\tfor(var i = 0; i < deferredModules.length; i++) {\n \t\t\tvar deferredModule = deferredModules[i];\n \t\t\tvar fulfilled = true;\n \t\t\tfor(var j = 1; j < deferredModule.length; j++) {\n \t\t\t\tvar depId = deferredModule[j];\n \t\t\t\tif(installedChunks[depId] !== 0) fulfilled = false;\n \t\t\t}\n \t\t\tif(fulfilled) {\n \t\t\t\tdeferredModules.splice(i--, 1);\n \t\t\t\tresult = __webpack_require__(__webpack_require__.s = deferredModule[0]);\n \t\t\t}\n \t\t}\n\n \t\treturn result;\n \t}\n\n \t// The module cache\n \tvar installedModules = {};\n\n \t// object to store loaded and loading chunks\n \t// undefined = chunk not loaded, null = chunk preloaded/prefetched\n \t// Promise = chunk loading, 0 = chunk loaded\n \tvar installedChunks = {\n \t\t1: 0\n \t};\n\n \tvar deferredModules = [];\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"/\";\n\n \tvar jsonpArray = this[\"webpackJsonpclient\"] = this[\"webpackJsonpclient\"] || [];\n \tvar oldJsonpFunction = jsonpArray.push.bind(jsonpArray);\n \tjsonpArray.push = webpackJsonpCallback;\n \tjsonpArray = jsonpArray.slice();\n \tfor(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);\n \tvar parentJsonpFunction = oldJsonpFunction;\n\n\n \t// run deferred modules from other chunks\n \tcheckDeferredModules();\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /client/src/pages/Register/Register.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Formik, Form } from 'formik'; 3 | import { 4 | useRegisterMutation, 5 | RegisterMutationVariables, 6 | RegisterMutation, 7 | } from '../../generated/graphql'; 8 | import { RouteComponentProps } from 'react-router-dom'; 9 | import { Button, ThemeProvider } from '@material-ui/core'; 10 | import { theme } from '../../utils/theme'; 11 | import { registerValidationSchema } from '../../schemas/registerValidationSchema'; 12 | import { FormTextField, FormDatePicker } from '../../components/Forms/FormTextField'; 13 | import { ErrorMessage } from '../../components/Alerts/AlertMessage'; 14 | import { useRegisterStyles } from './Register.style'; 15 | import { MutationTuple } from '@apollo/react-hooks'; 16 | 17 | export const Register: React.FC<RouteComponentProps> = ({ history }) => { 18 | // GraphQL Mutations 19 | const [register]: MutationTuple< 20 | RegisterMutation, 21 | RegisterMutationVariables 22 | > = useRegisterMutation(); 23 | 24 | // State 25 | const [errorMessage, setErrorMessage] = useState<string>(''); 26 | 27 | const classes = useRegisterStyles(); 28 | 29 | return ( 30 | <div> 31 | <div> 32 | <h1 className={classes.headerText}>Sign Up</h1> 33 | </div> 34 | {errorMessage.length > 0 && ( 35 | <div style={{ display: 'flex', justifyContent: 'center' }}> 36 | <ErrorMessage message={errorMessage} /> 37 | </div> 38 | )} 39 | <Formik 40 | initialValues={{ 41 | firstName: '', 42 | lastName: '', 43 | streetAddres: '', 44 | postCode: '', 45 | city: '', 46 | country: '', 47 | email: '', 48 | password: '', 49 | confirmPassword: '', 50 | dateOfBirth: '', 51 | }} 52 | validationSchema={registerValidationSchema} 53 | onSubmit={async (data, { setSubmitting, resetForm }) => { 54 | setSubmitting(true); 55 | 56 | // On the register button click, call the register mutation 57 | const response = await register({ 58 | variables: { 59 | firstName: data.firstName, 60 | lastName: data.lastName, 61 | email: data.email, 62 | password: data.password, 63 | streetAddress: data.streetAddres, 64 | postCode: data.postCode, 65 | city: data.city, 66 | country: data.country, 67 | dateOfBirth: data.dateOfBirth, 68 | }, 69 | }); 70 | 71 | // if the register was successful, route the user to the login page 72 | if (response.data?.register) { 73 | history.push('/login'); 74 | setSubmitting(false); 75 | resetForm(); 76 | } else { 77 | setErrorMessage('User with that email already exists.'); 78 | setSubmitting(false); 79 | } 80 | }} 81 | > 82 | {({ isSubmitting }) => ( 83 | <div className={classes.root}> 84 | <Form onChange={() => setErrorMessage('')}> 85 | <div className={classes.alignedFormContent}> 86 | <FormTextField 87 | className={classes.alignedFormField} 88 | name="firstName" 89 | placeholder="First name" 90 | type="input" 91 | /> 92 | <div className={classes.spacer} /> 93 | <FormTextField 94 | className={classes.alignedFormField} 95 | name="lastName" 96 | placeholder="Last name" 97 | type="input" 98 | /> 99 | </div> 100 | <div> 101 | <div className={classes.alignedFormContent}> 102 | <FormTextField 103 | className={classes.alignedFormField} 104 | name="streetAddres" 105 | placeholder="Street address" 106 | type="input" 107 | /> 108 | <div className={classes.spacer} /> 109 | <FormTextField 110 | className={classes.alignedFormField} 111 | name="postCode" 112 | placeholder="Post code" 113 | type="input" 114 | /> 115 | </div> 116 | <div className={classes.alignedFormContent}> 117 | <FormTextField 118 | className={classes.alignedFormField} 119 | name="city" 120 | placeholder="City" 121 | type="input" 122 | /> 123 | <div className={classes.spacer} /> 124 | <FormTextField 125 | className={classes.alignedFormField} 126 | name="country" 127 | placeholder="Country" 128 | type="input" 129 | /> 130 | </div> 131 | <FormTextField 132 | className={classes.formField} 133 | name="email" 134 | placeholder="Email" 135 | type="input" 136 | /> 137 | <FormTextField 138 | className={classes.formField} 139 | name="password" 140 | placeholder="Password" 141 | type="password" 142 | /> 143 | <FormTextField 144 | className={classes.formField} 145 | name="confirmPassword" 146 | placeholder="Confirm password" 147 | type="password" 148 | /> 149 | <FormDatePicker className={classes.formField} name="dateOfBirth" /> 150 | </div> 151 | <div className={classes.formButton}> 152 | <ThemeProvider theme={theme}> 153 | <Button 154 | className={classes.formButton} 155 | disabled={isSubmitting} 156 | variant="contained" 157 | color="secondary" 158 | type="submit" 159 | > 160 | Sign Up 161 | </Button> 162 | </ThemeProvider> 163 | </div> 164 | <div className={classes.loginText}> 165 | <p> 166 | Already have an account? <a href="/login">Login here.</a> 167 | </p> 168 | </div> 169 | </Form> 170 | </div> 171 | )} 172 | </Formik> 173 | </div> 174 | ); 175 | }; 176 | -------------------------------------------------------------------------------- /server/src/resolvers/AccountResolver.ts: -------------------------------------------------------------------------------- 1 | import { ErrorMessages } from "./../utils/messages"; 2 | import { createRandomBicCode } from "./../utils/createRandom"; 3 | import { isAuth } from "../middleware"; 4 | import { 5 | Query, 6 | Resolver, 7 | Mutation, 8 | Ctx, 9 | UseMiddleware, 10 | Arg, 11 | ObjectType, 12 | Field, 13 | } from "type-graphql"; 14 | import { MyContext } from "../MyContext"; 15 | import { User } from "../entity/User"; 16 | import { Account } from "../entity/Account"; 17 | import { createRandomSortCode, createRandomIbanCode } from "../utils/createRandom"; 18 | import { SuccessMessages } from "../utils/messages"; 19 | 20 | @ObjectType() 21 | class AccountResponse { 22 | @Field(() => Account) 23 | account: Account; 24 | 25 | @Field(() => String) 26 | message: String; 27 | } 28 | 29 | @Resolver() 30 | export class AccountResolver { 31 | /** 32 | * Used to query and find all the accounts for an authenticated user 33 | * @param param0 34 | */ 35 | @Query(() => [Account]) 36 | @UseMiddleware(isAuth) 37 | async accounts(@Ctx() { payload }: MyContext) { 38 | if (!payload) { 39 | return null; 40 | } 41 | 42 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 43 | 44 | if (owner) { 45 | return Account.find({ where: { owner: owner } }); 46 | } 47 | 48 | return null; 49 | } 50 | 51 | /** 52 | * Used to query and find a single account specified by the currency (EUR account, USD account or GBP account) 53 | * @param currency 54 | * @param param1 55 | */ 56 | @Query(() => Account) 57 | @UseMiddleware(isAuth) 58 | async account( 59 | @Arg("currency") currency: string, 60 | @Ctx() { payload }: MyContext 61 | ): Promise<Account | undefined> { 62 | if (!payload) { 63 | throw new Error(""); 64 | } 65 | 66 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 67 | 68 | if (owner) { 69 | const account = Account.findOne({ where: { owner: owner, currency: currency } }); 70 | 71 | if (account) { 72 | return account; 73 | } 74 | } 75 | 76 | return undefined; 77 | } 78 | 79 | /** 80 | * Mutation which allows a user to add money into their account 81 | * @param amount 82 | * @param currency 83 | * @param param2 84 | */ 85 | @Mutation(() => AccountResponse) 86 | @UseMiddleware(isAuth) 87 | async addMoney( 88 | @Arg("amount") amount: number, 89 | @Arg("currency") currency: string, 90 | @Ctx() { payload }: MyContext 91 | ): Promise<AccountResponse | null> { 92 | if (!payload) { 93 | return null; 94 | } 95 | 96 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 97 | 98 | if (owner) { 99 | const account: Account | undefined = await Account.findOne({ 100 | where: { owner: owner, currency: currency }, 101 | }); 102 | 103 | // If an account for the specified owner exists, add money and update the account balance 104 | if (account) { 105 | try { 106 | await Account.update({ id: account.id }, { balance: account.balance + amount }); 107 | } catch (err) { 108 | throw new Error(ErrorMessages.ADD_MONEY); 109 | } 110 | } 111 | } 112 | 113 | // Return the updated account 114 | try { 115 | const updatedAccount: Account | undefined = await Account.findOne({ 116 | where: { owner: owner, currency: currency }, 117 | }); 118 | 119 | if (updatedAccount) { 120 | return { 121 | account: updatedAccount, 122 | message: SuccessMessages.ADD_MONEY, 123 | }; 124 | } 125 | } catch (error) { 126 | throw new Error(ErrorMessages.ADD_MONEY); 127 | } 128 | return null; 129 | } 130 | 131 | /** 132 | * Mutation which allows a user to exchange money from one account to another 133 | * @param selectedAccountCurrency 134 | * @param toAccountCurrency 135 | * @param amount 136 | * @param param3 137 | */ 138 | @Mutation(() => AccountResponse) 139 | @UseMiddleware(isAuth) 140 | async exchange( 141 | @Arg("selectedAccountCurrency") selectedAccountCurrency: string, 142 | @Arg("toAccountCurrency") toAccountCurrency: string, 143 | @Arg("amount") amount: number, 144 | @Ctx() { payload }: MyContext 145 | ): Promise<AccountResponse | null> { 146 | if (!payload) { 147 | return null; 148 | } 149 | 150 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 151 | 152 | if (owner) { 153 | const currentAccount: Account | undefined = await Account.findOne({ 154 | where: { owner: owner, currency: selectedAccountCurrency }, 155 | }); 156 | 157 | if (currentAccount) { 158 | if (currentAccount.balance >= amount) { 159 | // Exchange the amount to the other account 160 | const toAccount: Account | undefined = await Account.findOne({ 161 | where: { 162 | owner: owner, 163 | currency: toAccountCurrency, 164 | }, 165 | }); 166 | 167 | if (toAccount) { 168 | try { 169 | let amountWithConversion: number = 0; 170 | 171 | // Apply conversion rates for each currency 172 | if (selectedAccountCurrency === "EUR" && toAccountCurrency === "USD") { 173 | amountWithConversion = amount * 1.11; 174 | } else if (selectedAccountCurrency === "EUR" && toAccountCurrency === "GBP") { 175 | amountWithConversion = amount * 0.89; 176 | } else if (selectedAccountCurrency === "USD" && toAccountCurrency === "EUR") { 177 | amountWithConversion = amount * 0.9; 178 | } else if (selectedAccountCurrency === "USD" && toAccountCurrency === "GBP") { 179 | amountWithConversion = amount * 0.8; 180 | } else if (selectedAccountCurrency === "GBP" && toAccountCurrency === "USD") { 181 | amountWithConversion = amount * 1.25; 182 | } else if (selectedAccountCurrency === "GBP" && toAccountCurrency === "EUR") { 183 | amountWithConversion = amount * 1.13; 184 | } 185 | 186 | // Only update the account balances if the current accounts balance doesn't fall below 0 after applying conversion rates 187 | if (currentAccount.balance - amount >= 0) { 188 | await Account.update( 189 | { id: toAccount.id }, 190 | { balance: toAccount.balance + Math.round(amountWithConversion) } 191 | ); 192 | await Account.update( 193 | { id: currentAccount.id }, 194 | { balance: currentAccount.balance - amount } 195 | ); 196 | } else { 197 | throw new Error(ErrorMessages.EXCHANGE); 198 | } 199 | } catch (error) { 200 | console.log(error); 201 | throw new Error(ErrorMessages.EXCHANGE); 202 | } 203 | } 204 | } else { 205 | throw new Error(ErrorMessages.EXCHANGE); 206 | } 207 | } 208 | } 209 | 210 | try { 211 | const updatedAccount = await Account.findOne({ 212 | where: { owner: owner, currency: selectedAccountCurrency }, 213 | }); 214 | 215 | if (updatedAccount) { 216 | return { 217 | account: updatedAccount, 218 | message: SuccessMessages.EXCHANGE, 219 | }; 220 | } 221 | } catch (error) { 222 | throw new Error(ErrorMessages.EXCHANGE); 223 | } 224 | 225 | return null; 226 | } 227 | 228 | /** 229 | * Mutation for creating a new currency account 230 | * @param currency 231 | * @param param1 232 | */ 233 | @Mutation(() => Boolean) 234 | @UseMiddleware(isAuth) 235 | async createAccount(@Arg("currency") currency: string, @Ctx() { payload }: MyContext) { 236 | if (!payload) { 237 | return false; 238 | } 239 | 240 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 241 | 242 | if (owner) { 243 | const account: Account | undefined = await Account.findOne({ 244 | where: { owner: owner, currency: currency }, 245 | }); 246 | 247 | if (account) { 248 | throw new Error(`You already have a ${currency} account`); 249 | } else { 250 | try { 251 | await Account.insert({ 252 | owner, 253 | currency, 254 | sortCode: currency === "GBP" ? createRandomSortCode() : "00-00-00", 255 | iban: createRandomIbanCode(), 256 | bic: createRandomBicCode(), 257 | }); 258 | } catch (err) { 259 | console.log(err); 260 | return false; 261 | } 262 | } 263 | } 264 | return true; 265 | } 266 | 267 | /** 268 | * Mutation for deleting a specified currency account 269 | * @param currency 270 | * @param param1 271 | */ 272 | @Mutation(() => Boolean) 273 | @UseMiddleware(isAuth) 274 | async deleteAccount(@Arg("currency") currency: string, @Ctx() { payload }: MyContext) { 275 | if (!payload) { 276 | return false; 277 | } 278 | 279 | const owner: User | undefined = await User.findOne({ where: { id: payload.userId } }); 280 | 281 | if (owner) { 282 | const account: Account | undefined = await Account.findOne({ 283 | where: { owner: owner, currency: currency }, 284 | }); 285 | 286 | // If the account exists, only allow the removal of an account if the user has no debt or if the users account is empty 287 | if (account) { 288 | if (account.balance == 0) { 289 | try { 290 | await Account.delete({ 291 | id: account.id, 292 | }); 293 | } catch (error) { 294 | console.log(error); 295 | return false; 296 | } 297 | } else if (account.balance < 0) { 298 | throw new Error(ErrorMessages.BALANCE_LESS_THAN); 299 | } else if (account.balance > 0) { 300 | throw new Error(ErrorMessages.BALANCE_GREATER_THAN); 301 | } 302 | } 303 | } 304 | return true; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /client/src/pages/Dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, MouseEvent, ChangeEvent } from 'react'; 2 | import { 3 | Container, 4 | Grid, 5 | Paper, 6 | List, 7 | ListItemText, 8 | ListItem, 9 | ListItemIcon, 10 | FormControl, 11 | InputLabel, 12 | Select, 13 | MenuItem, 14 | ThemeProvider, 15 | } from '@material-ui/core'; 16 | import { Chart } from '../../components/Charts/Chart'; 17 | import { Title } from '../../components/Typography/Title'; 18 | import { ReactComponent as Euro } from '../../assets/world.svg'; 19 | import { ReactComponent as Dollar } from '../../assets/flag.svg'; 20 | import { ReactComponent as Pound } from '../../assets/uk.svg'; 21 | import { 22 | useAccountsQuery, 23 | useCreateAccountMutation, 24 | AccountsDocument, 25 | useCreateCardMutation, 26 | useCardsQuery, 27 | CardsDocument, 28 | AccountsQueryResult, 29 | CardsQueryResult, 30 | CreateAccountMutation, 31 | CreateAccountMutationVariables, 32 | CreateCardMutation, 33 | CreateCardMutationVariables, 34 | Account, 35 | } from '../../generated/graphql'; 36 | import { Loading } from '../../components/Loading/Loading'; 37 | import { useHistory } from 'react-router-dom'; 38 | import { AccountsCard, NoAccountsCard } from '../../components/Cards/AccountsCard'; 39 | import { Dialog } from '../../components/Dialog/Dialog'; 40 | import { NoApolloCard, ApolloCard } from '../../components/Cards/ApolloCard'; 41 | import { MutationTuple } from '@apollo/react-hooks'; 42 | import { ExecutionResult } from 'graphql'; 43 | import { theme } from '../../utils/theme'; 44 | import { useDashboardStyles } from './styles/Dashboard.style'; 45 | 46 | const GLOBAL_CURRENCIES: string[] = ['EUR', 'USD', 'GBP']; 47 | 48 | export const Dashboard: React.FC = () => { 49 | // GraphQL Mutations 50 | const [createAccount]: MutationTuple< 51 | CreateAccountMutation, 52 | CreateAccountMutationVariables 53 | > = useCreateAccountMutation(); 54 | const [createCard]: MutationTuple< 55 | CreateCardMutation, 56 | CreateCardMutationVariables 57 | > = useCreateCardMutation(); 58 | 59 | // GraphQL Queries 60 | const { data, loading }: AccountsQueryResult = useAccountsQuery(); 61 | const cards: CardsQueryResult = useCardsQuery(); 62 | 63 | // State 64 | const [currencies, setCurrencies] = useState<string[]>(['']); 65 | const [analyticsAccount, setAnalyticsAccount] = useState<string>(''); 66 | const [totalBalance, setTotalBalance] = useState<number>(0); 67 | const [openDialog, setOpenDialog] = useState<boolean>(false); 68 | 69 | const history = useHistory(); 70 | 71 | const classes = useDashboardStyles(); 72 | 73 | const accountCardHeightPaper = classes.paper + ' ' + classes.accountCardHeight; 74 | const apolloCardPaper = 75 | classes.paper + ' ' + classes.accountCardHeight + ' ' + classes.apolloCard; 76 | const chartPaper = classes.paper + ' ' + classes.chart; 77 | 78 | // When the component mounts, update the total balance for the current user 79 | // and store the currency accounts in the state array 80 | useEffect(() => { 81 | let currencies: string[] = []; 82 | let balance: number = 0; 83 | if (data) { 84 | data.accounts.forEach((account: Account) => { 85 | // apply conversion rates 86 | if (account.currency === 'EUR') { 87 | balance += Math.round(account.balance / 1.13); 88 | } 89 | 90 | if (account.currency === 'USD') { 91 | balance += Math.round(account.balance / 1.25); 92 | } 93 | 94 | if (account.currency === 'GBP') { 95 | balance += Math.round(account.balance * 1); 96 | } 97 | currencies.push(account.currency); 98 | }); 99 | } 100 | setTotalBalance(balance); 101 | setCurrencies(currencies); 102 | }, [loading, data]); 103 | 104 | // When the component mounts, if an exists, set the analytics to display data for the first account 105 | useEffect(() => { 106 | if (currencies.length > 0) { 107 | setAnalyticsAccount(currencies[0]); 108 | } 109 | }, [currencies]); 110 | 111 | if (!data) { 112 | return ( 113 | <div 114 | style={{ 115 | position: 'fixed', 116 | top: '50%', 117 | left: '50%', 118 | transform: 'translate(-50%, -50%)', 119 | }} 120 | > 121 | <Loading /> 122 | </div> 123 | ); 124 | } 125 | 126 | const determineCurrencyIcon = (c: string): JSX.Element | undefined => { 127 | switch (c) { 128 | case 'EUR': 129 | return <Euro />; 130 | case 'USD': 131 | return <Dollar />; 132 | case 'GBP': 133 | return <Pound />; 134 | } 135 | return undefined; 136 | }; 137 | 138 | const renderDialog = (): JSX.Element => { 139 | return ( 140 | <Dialog isOpen={openDialog} onClose={() => setOpenDialog(false)}> 141 | <List> 142 | {GLOBAL_CURRENCIES.map((currency: string) => ( 143 | <ListItem 144 | button 145 | key={currency} 146 | onClick={async () => { 147 | // Call the createAccount mutation 148 | try { 149 | const response: ExecutionResult<CreateAccountMutation> = await createAccount( 150 | { 151 | variables: { 152 | currency: currency, 153 | }, 154 | refetchQueries: [ 155 | { 156 | query: AccountsDocument, 157 | variables: {}, 158 | }, 159 | { 160 | query: CardsDocument, 161 | variables: {}, 162 | }, 163 | ], 164 | }, 165 | ); 166 | 167 | if (response && response.data) { 168 | setAnalyticsAccount(currency); 169 | setOpenDialog(false); 170 | } 171 | } catch (error) { 172 | const errorMessage: string = error.message.split(':')[1]; 173 | console.log(errorMessage); 174 | } 175 | }} 176 | > 177 | <ListItemIcon> 178 | <div style={{ width: 32 }}>{determineCurrencyIcon(currency)}</div> 179 | </ListItemIcon> 180 | <ListItemText primary={currency} /> 181 | </ListItem> 182 | ))} 183 | </List> 184 | </Dialog> 185 | ); 186 | }; 187 | 188 | const handleAccountClicked = (e: MouseEvent<HTMLButtonElement>, account: Account): void => { 189 | e.preventDefault(); 190 | history.push({ 191 | pathname: `/accounts/${account.id}`, 192 | state: account, 193 | }); 194 | }; 195 | 196 | const handleCreateNewCardClicked = async (e: MouseEvent<HTMLButtonElement>): Promise<void> => { 197 | e.preventDefault(); 198 | 199 | try { 200 | // Call the createCard mutation 201 | const response: ExecutionResult<CreateCardMutation> = await createCard({ 202 | variables: {}, 203 | refetchQueries: [ 204 | { 205 | query: CardsDocument, 206 | variables: {}, 207 | }, 208 | ], 209 | }); 210 | 211 | if (response && response.data) { 212 | console.log('Card successfully created!'); 213 | } 214 | } catch (error) { 215 | const errorMessage: string = error.message.split(':')[1]; 216 | console.log(errorMessage); 217 | } 218 | }; 219 | 220 | const renderNoAccountsCard = (): JSX.Element => { 221 | return ( 222 | <> 223 | <Grid item xs={12} md={4} lg={4}> 224 | <Paper className={accountCardHeightPaper}> 225 | <NoAccountsCard 226 | onCreateNewAccountClicked={(e: MouseEvent<HTMLButtonElement>) => { 227 | e.preventDefault(); 228 | setOpenDialog(true); 229 | }} 230 | /> 231 | </Paper> 232 | </Grid> 233 | </> 234 | ); 235 | }; 236 | 237 | const renderNoApolloCard = (): JSX.Element => { 238 | return ( 239 | <> 240 | <Grid item xs={12} md={4} lg={4}> 241 | <Paper className={accountCardHeightPaper}> 242 | <NoApolloCard 243 | onCreateNewCardClicked={(e: MouseEvent<HTMLButtonElement>) => { 244 | handleCreateNewCardClicked(e); 245 | }} 246 | /> 247 | </Paper> 248 | </Grid> 249 | </> 250 | ); 251 | }; 252 | 253 | const renderChartOptions = (): JSX.Element => { 254 | return ( 255 | <> 256 | <ThemeProvider theme={theme}> 257 | <FormControl> 258 | <InputLabel id="select-filled-label">Account</InputLabel> 259 | <Select 260 | labelId="select-filled-label" 261 | id="select-filled" 262 | value={analyticsAccount} 263 | onChange={(event: ChangeEvent<{ value: unknown }>) => 264 | setAnalyticsAccount(event.target.value as string) 265 | } 266 | label="Account" 267 | > 268 | {currencies.map((currency: string) => { 269 | return ( 270 | <MenuItem key={currency} value={currency}> 271 | {currency} 272 | </MenuItem> 273 | ); 274 | })} 275 | </Select> 276 | </FormControl> 277 | </ThemeProvider> 278 | </> 279 | ); 280 | }; 281 | 282 | return ( 283 | <div className={classes.root}> 284 | {renderDialog()} 285 | <main className={classes.content}> 286 | <Container maxWidth="lg" className={classes.container}> 287 | <div 288 | style={{ 289 | marginBottom: 12, 290 | display: 'flex', 291 | justifyContent: 'space-between', 292 | }} 293 | > 294 | <div> 295 | <Title title="Analytics" fontSize={24} /> 296 | </div> 297 | </div> 298 | {data && data.accounts.length > 0 ? renderChartOptions() : undefined} 299 | <Grid container spacing={3}> 300 | <Grid item xs={12} md={12} lg={12}> 301 | <Paper className={chartPaper}> 302 | <Chart currency={!!analyticsAccount ? analyticsAccount : 'EUR'} /> 303 | </Paper> 304 | </Grid> 305 | </Grid> 306 | </Container> 307 | <Container maxWidth="lg" className={classes.container}> 308 | <div 309 | style={{ 310 | marginBottom: 12, 311 | marginTop: 12, 312 | display: 'flex', 313 | justifyContent: 'space-between', 314 | }} 315 | > 316 | <div> 317 | <Title title="Accounts" fontSize={24} /> 318 | </div> 319 | <div 320 | style={{ 321 | fontSize: 18, 322 | fontWeight: 'bold', 323 | color: 'rgba(0, 0, 0, 0.3)', 324 | }} 325 | > 326 | Total balance: £{totalBalance} 327 | </div> 328 | </div> 329 | <Grid container spacing={3}> 330 | {data.accounts.length > 0 && 331 | data.accounts.map((account: Account) => { 332 | let svg: any | string; 333 | let currencyIcon: string = ''; 334 | let fullCurrencyText: string = ''; 335 | 336 | switch (account.currency) { 337 | case GLOBAL_CURRENCIES[0]: 338 | svg = <Euro />; 339 | currencyIcon = '€'; 340 | fullCurrencyText = 'Euro'; 341 | break; 342 | case GLOBAL_CURRENCIES[1]: 343 | svg = <Dollar />; 344 | currencyIcon = '$'; 345 | fullCurrencyText = 'US Dollar'; 346 | break; 347 | case GLOBAL_CURRENCIES[2]: 348 | svg = <Pound />; 349 | currencyIcon = '£'; 350 | fullCurrencyText = 'British Pound'; 351 | break; 352 | } 353 | return ( 354 | <Grid key={account.id} item xs={12} md={4} lg={4}> 355 | <Paper className={accountCardHeightPaper}> 356 | <AccountsCard 357 | svg={svg} 358 | currencyIcon={currencyIcon} 359 | fullCurrencyText={fullCurrencyText} 360 | balance={account.balance} 361 | iban={account.iban} 362 | onAccountClicked={( 363 | e: MouseEvent<HTMLButtonElement>, 364 | ) => handleAccountClicked(e, account)} 365 | /> 366 | </Paper> 367 | </Grid> 368 | ); 369 | })} 370 | {data.accounts.length <= 2 && renderNoAccountsCard()} 371 | </Grid> 372 | </Container> 373 | <Container maxWidth="lg" className={classes.container}> 374 | <div style={{ marginBottom: 12 }}> 375 | <Title title="Cards" fontSize={24} /> 376 | </div> 377 | <Grid container spacing={3}> 378 | {cards.data && 379 | cards.data.cards && 380 | cards.data.cards.length > 0 && 381 | cards.data.cards.map(card => { 382 | return ( 383 | <Grid key={card.id} item xs={12} md={4} lg={4}> 384 | <Paper className={apolloCardPaper}> 385 | <ApolloCard 386 | cardNumber={card.cardNumber} 387 | validThru={ 388 | new Date( 389 | Date.parse(card.expiresIn), 390 | ).getMonth() + 391 | '/' + 392 | new Date(Date.parse(card.expiresIn)) 393 | .getFullYear() 394 | .toString() 395 | .substr(-2) 396 | } 397 | cvv={card.cvv} 398 | /> 399 | </Paper> 400 | </Grid> 401 | ); 402 | })} 403 | {cards.data && cards.data.cards.length <= 2 && renderNoApolloCard()} 404 | </Grid> 405 | </Container> 406 | </main> 407 | </div> 408 | ); 409 | }; 410 | --------------------------------------------------------------------------------