├── .DS_Store ├── .dockerignore ├── .env.example ├── .gitignore ├── .idea ├── .gitignore ├── mern-invoice.iml ├── modules.xml └── vcs.xml ├── Makefile ├── backend ├── config │ ├── cloudinaryConfig.js │ ├── connectDB.js │ └── passportSetup.js ├── constants │ └── index.js ├── controllers │ ├── auth │ │ ├── loginController.js │ │ ├── logoutController.js │ │ ├── passwordResetController.js │ │ ├── refreshTokenController.js │ │ ├── registerController.js │ │ ├── resendVerifyEmailController.js │ │ └── verifyEmailController.js │ ├── customers │ │ ├── createCustomer.js │ │ ├── deleteCustomer.js │ │ ├── getAllUserCustomers.js │ │ ├── getSingleUserCustomer.js │ │ └── updateCustomerInfo.js │ ├── documents │ │ ├── createDocument.js │ │ ├── createPayment.js │ │ ├── deleteDocument.js │ │ ├── generatePDF.js │ │ ├── getAllUserDocuments.js │ │ ├── getSingleUserDocument.js │ │ └── updateDocument.js │ └── user │ │ ├── deactivateUser.js │ │ ├── deleteMyAccount.js │ │ ├── deleteUserAccount.js │ │ ├── getAllUserAccounts.js │ │ ├── getUserProfile.js │ │ └── updateUserProfile.js ├── helpers │ ├── emailTransport.js │ └── multer.js ├── middleware │ ├── apiLimiter.js │ ├── checkAuthMiddleware.js │ ├── errorMiddleware.js │ └── roleMiddleware.js ├── models │ ├── customerModel.js │ ├── documentModel.js │ ├── userModel.js │ └── verifyResetTokenModel.js ├── routes │ ├── authRoutes.js │ ├── customerRoutes.js │ ├── documentRoutes.js │ ├── uploadRoutes.js │ └── userRoutes.js ├── server.js └── utils │ ├── Logger.js │ ├── emails │ └── template │ │ ├── accountVerification.handlebars │ │ ├── requestResetPassword.handlebars │ │ ├── resetPassword.handlebars │ │ └── welcome.handlebars │ ├── pdf │ ├── emailTemplate.js │ ├── options.js │ └── pdfTemplate.js │ └── sendEmail.js ├── client ├── .DS_Store ├── .dockerignore ├── README.md ├── build │ ├── asset-manifest.json │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── robots.txt │ └── static │ │ ├── css │ │ ├── main.425bd0e3.css │ │ └── main.425bd0e3.css.map │ │ ├── js │ │ ├── 787.c4e7f8f9.chunk.js │ │ ├── 787.c4e7f8f9.chunk.js.map │ │ ├── main.e67fa813.js │ │ ├── main.e67fa813.js.LICENSE.txt │ │ └── main.e67fa813.js.map │ │ └── media │ │ ├── add_bill.f6fc81c86179dd1c7ca4bcff6d087367.svg │ │ ├── add_customer.8a9aa94a02fc33d6ea5031ca81f510bc.svg │ │ └── buildings.ec0090336eaa34291eb9.jpg ├── docker │ └── local │ │ └── Dockerfile ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.jsx │ ├── animations │ └── authButtonAnimations.js │ ├── app │ └── store.js │ ├── components │ ├── AuthRequired.jsx │ ├── Footer.jsx │ ├── GoogleLogin.jsx │ ├── Layout.jsx │ ├── Navbar │ │ ├── AuthNav.jsx │ │ ├── Logo.jsx │ │ ├── MenuList.jsx │ │ ├── ProfileInfo.jsx │ │ └── index.jsx │ ├── NormalDivider.jsx │ ├── NotFound.jsx │ ├── Spinner.jsx │ ├── StyledContainer.jsx │ ├── StyledDashboardGrid.jsx │ ├── StyledDivider.jsx │ ├── StyledTableCell.jsx │ ├── StyledTableRow.jsx │ └── TablePaginationActions.jsx │ ├── config │ └── roles.js │ ├── customTheme.js │ ├── features │ ├── api │ │ └── baseApiSlice.js │ ├── auth │ │ ├── authApiSlice.js │ │ ├── authSlice.js │ │ ├── forms │ │ │ ├── AuthWrapper.jsx │ │ │ ├── LoginForm.jsx │ │ │ └── RegisterForm.jsx │ │ └── pages │ │ │ ├── LoginPage.jsx │ │ │ ├── PasswordResetPage.jsx │ │ │ ├── PasswordResetRequestPage.jsx │ │ │ ├── RegisterPage.jsx │ │ │ ├── ResendEmailTokenPage.jsx │ │ │ └── VerifiedPage.jsx │ ├── customers │ │ ├── customersApiSlice.js │ │ └── pages │ │ │ ├── CustomerCreateForm.jsx │ │ │ ├── CustomerEditForm.jsx │ │ │ ├── CustomerSVG.jsx │ │ │ ├── CustomersPage.jsx │ │ │ └── SingleCustomerPage.jsx │ ├── dashboard │ │ └── pages │ │ │ ├── DashboardPage.jsx │ │ │ └── components │ │ │ └── paymentHistory.jsx │ ├── documents │ │ ├── documentsApiSlice.js │ │ └── pages │ │ │ ├── DocCreateEditForm.jsx │ │ │ ├── DocumentsPage.jsx │ │ │ ├── PaymentForm.jsx │ │ │ ├── SingleDocumentPage.jsx │ │ │ ├── components │ │ │ ├── DocumentSVG.jsx │ │ │ ├── DocumentType.jsx │ │ │ ├── PaymentDate.jsx │ │ │ ├── addCurrencyCommas.jsx │ │ │ └── styling.js │ │ │ └── initialState.jsx │ └── users │ │ ├── pages │ │ ├── EditProfileForm.jsx │ │ ├── ProfilePage.jsx │ │ └── UsersListPage.jsx │ │ └── usersApiSlice.js │ ├── hooks │ ├── useAuthUser.jsx │ └── useTitle.jsx │ ├── images │ ├── add_bill.svg │ ├── add_customer.svg │ ├── buildings.jpg │ ├── googleLogo.png │ └── profile_default.png │ ├── index.css │ ├── index.js │ ├── pages │ └── HomePage.jsx │ ├── reportWebVitals.js │ ├── styles │ ├── customer-button.css │ ├── homepage.css │ └── spinner.css │ ├── utils │ └── password-strength.js │ └── world_currencies.json ├── docker ├── digital_ocean_server_deploy.sh ├── local │ ├── express │ │ └── Dockerfile │ └── nginx │ │ ├── Dockerfile │ │ └── default.conf └── production │ └── express │ └── Dockerfile ├── docs └── myDocument.pdf ├── local.yml ├── package-lock.json ├── package.json └── production.yml /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/.DS_Store -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | .git 5 | node_modules/ 6 | .env 7 | .npm -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT= 2 | NODE_ENV= 3 | DB_NAME= 4 | MONGO_ROOT_USERNAME= 5 | MONGO_ROOT_PASSWORD= 6 | JWT_ACCESS_SECRET_KEY= 7 | JWT_REFRESH_SECRET_KEY= 8 | SENDER_EMAIL= 9 | DOMAIN= 10 | GOOGLE_CALLBACK_URL= 11 | GOOGLE_CLIENT_ID= 12 | GOOGLE_CLIENT_SECRET= 13 | CLOUDINARY_CLOUD_NAME= 14 | CLOUDINARY_API_KEY= 15 | CLOUDINARY_API_SECRET= 16 | MAILGUN_API_KEY= 17 | MAILGUN_DOMAIN= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | # Logs 3 | logs 4 | *.log 5 | uploads 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | .pnpm-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/mern-invoice.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | docker compose -f local.yml up --build -d --remove-orphans 3 | 4 | up: 5 | docker compose -f local.yml up -d 6 | 7 | down: 8 | docker compose -f local.yml down 9 | 10 | down-v: 11 | docker compose -f local.yml down -v 12 | 13 | show-logs: 14 | docker compose -f local.yml logs 15 | 16 | show-logs-api: 17 | docker compose -f local.yml logs api 18 | 19 | show-logs-client: 20 | docker compose -f local.yml logs client 21 | 22 | user: 23 | docker run --rm mern-invoice-api whoami 24 | 25 | volume: 26 | docker volume inspect mern-invoice_mongodb-data -------------------------------------------------------------------------------- /backend/config/cloudinaryConfig.js: -------------------------------------------------------------------------------- 1 | import { v2 as cloudinary } from "cloudinary"; 2 | import "dotenv/config"; 3 | import fs from "fs"; 4 | 5 | cloudinary.config({ 6 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 7 | api_key: process.env.CLOUDINARY_API_KEY, 8 | api_secret: process.env.CLOUDINARY_API_SECRET, 9 | }); 10 | 11 | const cloudinaryUploader = async function uploadToCloudinary(localFilePath) { 12 | const mainFolderName = "merninvoice"; 13 | 14 | const filePathOnCloudinary = mainFolderName + "/" + localFilePath; 15 | 16 | return cloudinary.uploader 17 | .upload(localFilePath, { public_id: filePathOnCloudinary }) 18 | .then((result) => { 19 | fs.unlinkSync(localFilePath); 20 | 21 | return { 22 | message: "Success", 23 | url: result.url, 24 | }; 25 | }) 26 | .catch((error) => { 27 | fs.unlinkSync(localFilePath); 28 | return { message: "Fail" }; 29 | }); 30 | }; 31 | 32 | export default cloudinaryUploader; 33 | -------------------------------------------------------------------------------- /backend/config/connectDB.js: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import mongoose from "mongoose"; 3 | import { systemLogs } from "../utils/Logger.js"; 4 | 5 | const connectionToDB = async () => { 6 | try { 7 | const connectionParams = { 8 | dbName: process.env.DB_NAME, 9 | }; 10 | const connect = await mongoose.connect( 11 | process.env.MONGO_URI, 12 | connectionParams 13 | ); 14 | console.log( 15 | `${chalk.blue.bold( 16 | `MongoDB Connected: ${connect.connection.host}` 17 | )}` 18 | ); 19 | systemLogs.info(`MongoDB Connected: ${connect.connection.host}`); 20 | } catch (error) { 21 | console.error(`${chalk.red.bold(`Error: ${error.message}`)}`); 22 | process.exit(1); 23 | } 24 | }; 25 | 26 | export default connectionToDB; 27 | -------------------------------------------------------------------------------- /backend/config/passportSetup.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import passport from "passport"; 3 | import GoogleStrategy from "passport-google-oauth20"; 4 | import User from "../models/userModel.js"; 5 | 6 | const domainURL = process.env.DOMAIN; 7 | 8 | const googleCallbackURL = process.env.GOOGLE_CALLBACK_URL; 9 | 10 | const googleAuth = () => { 11 | passport.use( 12 | new GoogleStrategy.Strategy( 13 | { 14 | clientID: process.env.GOOGLE_CLIENT_ID, 15 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 16 | callbackURL: `${domainURL}/api/v1/${googleCallbackURL}`, 17 | }, 18 | (accessToken, refreshToken, profile, done) => { 19 | // TODO: remove this console in production 20 | // console.log(profile); 21 | 22 | User.findOne({ googleID: profile.id }).then((user) => { 23 | if (!user) { 24 | const name = profile.displayName.split(" "); 25 | 26 | User.create({ 27 | username: profile._json.given_name, 28 | firstName: name[0], 29 | lastName: name[1], 30 | avatar: profile._json.picture, 31 | email: profile._json.email, 32 | googleID: profile.id, 33 | isEmailVerified: profile._json.email_verified, 34 | provider: "google", 35 | }) 36 | .then((user) => { 37 | done(null, user); 38 | }) 39 | .catch((err) => { 40 | return done(err, false); 41 | }); 42 | } else { 43 | done(null, user); 44 | } 45 | }); 46 | } 47 | ) 48 | ); 49 | }; 50 | 51 | export default googleAuth; 52 | -------------------------------------------------------------------------------- /backend/constants/index.js: -------------------------------------------------------------------------------- 1 | export const ADMIN = "Admin"; 2 | export const USER = "User"; 3 | -------------------------------------------------------------------------------- /backend/controllers/auth/loginController.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import jwt from "jsonwebtoken"; 3 | import User from "../../models/userModel.js"; 4 | import { systemLogs } from "../../utils/Logger.js"; 5 | 6 | // $-title Login User, get access and refresh tokens 7 | // $-path POST /api/v1/auth/login 8 | // $-auth Public 9 | 10 | const loginUser = asyncHandler(async (req, res) => { 11 | const { email, password } = req.body; 12 | 13 | if (!email || !password) { 14 | res.status(400); 15 | throw new Error("Please provide an email and password"); 16 | } 17 | 18 | const existingUser = await User.findOne({ email }).select("+password"); 19 | 20 | if (!existingUser || !(await existingUser.comparePassword(password))) { 21 | res.status(401); 22 | systemLogs.error("incorrect email or password"); 23 | throw new Error("Incorrect email or password"); 24 | } 25 | 26 | if (!existingUser.isEmailVerified) { 27 | res.status(400); 28 | throw new Error( 29 | "You are not verified. Check your email, a verification email link was sent when you registered" 30 | ); 31 | } 32 | 33 | if (!existingUser.active) { 34 | res.status(400); 35 | throw new Error( 36 | "You have been deactivated by the admin and login is impossible. Contact us for enquiries" 37 | ); 38 | } 39 | 40 | if (existingUser && (await existingUser.comparePassword(password))) { 41 | const accessToken = jwt.sign( 42 | { 43 | id: existingUser._id, 44 | roles: existingUser.roles, 45 | }, 46 | process.env.JWT_ACCESS_SECRET_KEY, 47 | { expiresIn: "10m" } 48 | ); 49 | 50 | const newRefreshToken = jwt.sign( 51 | { 52 | id: existingUser._id, 53 | }, 54 | process.env.JWT_REFRESH_SECRET_KEY, 55 | { expiresIn: "1d" } 56 | ); 57 | 58 | const cookies = req.cookies; 59 | 60 | let newRefreshTokenArray = !cookies?.jwt 61 | ? existingUser.refreshToken 62 | : existingUser.refreshToken.filter((refT) => refT !== cookies.jwt); 63 | 64 | if (cookies?.jwt) { 65 | const refreshToken = cookies.jwt; 66 | const existingRefreshToken = await User.findOne({ 67 | refreshToken, 68 | }).exec(); 69 | 70 | if (!existingRefreshToken) { 71 | newRefreshTokenArray = []; 72 | } 73 | 74 | const options = { 75 | httpOnly: true, 76 | maxAge: 24 * 60 * 60 * 1000, 77 | secure: true, 78 | sameSite: "None", 79 | }; 80 | 81 | res.clearCookie("jwt", options); 82 | } 83 | 84 | existingUser.refreshToken = [...newRefreshTokenArray, newRefreshToken]; 85 | await existingUser.save(); 86 | 87 | const options = { 88 | httpOnly: true, 89 | maxAge: 24 * 60 * 60 * 1000, 90 | secure: true, 91 | sameSite: "None", 92 | }; 93 | 94 | res.cookie("jwt", newRefreshToken, options); 95 | 96 | res.json({ 97 | success: true, 98 | firstName: existingUser.firstName, 99 | lastName: existingUser.lastName, 100 | username: existingUser.username, 101 | provider: existingUser.provider, 102 | avatar: existingUser.avatar, 103 | accessToken, 104 | }); 105 | } else { 106 | res.status(401); 107 | throw new Error("Invalid credentials provided"); 108 | } 109 | }); 110 | 111 | export default loginUser; 112 | -------------------------------------------------------------------------------- /backend/controllers/auth/logoutController.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | 4 | const logoutUser = asyncHandler(async (req, res) => { 5 | const cookies = req.cookies; 6 | 7 | if (!cookies?.jwt) { 8 | res.sendStatus(204); 9 | throw new Error("No cookie found"); 10 | } 11 | 12 | const refreshToken = cookies.jwt; 13 | 14 | const existingUser = await User.findOne({ refreshToken }); 15 | if (!existingUser) { 16 | res.clearCookie("jwt", { 17 | httpOnly: true, 18 | secure: true, 19 | sameSite: "None", 20 | }); 21 | res.sendStatus(204); 22 | } 23 | 24 | existingUser.refreshToken = existingUser.refreshToken.filter( 25 | (refT) => refT !== refreshToken 26 | ); 27 | await existingUser.save(); 28 | 29 | res.clearCookie("jwt", { 30 | httpOnly: true, 31 | secure: true, 32 | sameSite: "None", 33 | }); 34 | 35 | res.status(200).json({ 36 | success: true, 37 | message: `${existingUser.firstName},you have been logged out successfully`, 38 | }); 39 | }); 40 | 41 | export default logoutUser; 42 | -------------------------------------------------------------------------------- /backend/controllers/auth/passwordResetController.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | import VerificationToken from "../../models/verifyResetTokenModel.js"; 4 | import sendEmail from "../../utils/sendEmail.js"; 5 | const domainURL = process.env.DOMAIN; 6 | const { randomBytes } = await import("crypto"); 7 | 8 | // $-title Send password reset email link 9 | // $-path POST /api/v1/auth/reset_password_request 10 | // $-auth Public 11 | 12 | const resetPasswordRequest = asyncHandler(async (req, res) => { 13 | const { email } = req.body; 14 | 15 | if (!email) { 16 | res.status(400); 17 | throw new Error("You must enter your email address"); 18 | } 19 | 20 | const existingUser = await User.findOne({ email }).select( 21 | "-passwordConfirm" 22 | ); 23 | 24 | if (!existingUser) { 25 | res.status(400); 26 | throw new Error("That email is not associated with any account"); 27 | } 28 | 29 | let verificationToken = await VerificationToken.findOne({ 30 | _userId: existingUser._id, 31 | }); 32 | 33 | if (verificationToken) { 34 | await verificationToken.deleteOne(); 35 | } 36 | 37 | const resetToken = randomBytes(32).toString("hex"); 38 | 39 | let newVerificationToken = await new VerificationToken({ 40 | _userId: existingUser._id, 41 | token: resetToken, 42 | createdAt: Date.now(), 43 | }).save(); 44 | 45 | if (existingUser && existingUser.isEmailVerified) { 46 | const emailLink = `${domainURL}/auth/reset_password?emailToken=${newVerificationToken.token}&userId=${existingUser._id}`; 47 | 48 | const payload = { 49 | name: existingUser.firstName, 50 | link: emailLink, 51 | }; 52 | 53 | await sendEmail( 54 | existingUser.email, 55 | "Password Reset Request", 56 | payload, 57 | "./emails/template/requestResetPassword.handlebars" 58 | ); 59 | 60 | res.status(200).json({ 61 | success: true, 62 | message: `Hey ${existingUser.firstName}, an email has been sent to your account with the password reset link`, 63 | }); 64 | } 65 | }); 66 | 67 | // $-title Reset User Password 68 | // $-path POST /api/v1/auth/reset_password 69 | // $-auth Public 70 | 71 | const resetPassword = asyncHandler(async (req, res) => { 72 | const { password, passwordConfirm, userId, emailToken } = req.body; 73 | 74 | if (!password) { 75 | res.status(400); 76 | throw new Error("A password is required"); 77 | } 78 | if (!passwordConfirm) { 79 | res.status(400); 80 | throw new Error("A confirm password field is required"); 81 | } 82 | 83 | if (password !== passwordConfirm) { 84 | res.status(400); 85 | throw new Error("Passwords do not match"); 86 | } 87 | 88 | if (password.length < 8) { 89 | res.status(400); 90 | throw new Error("Passwords must be at least 8 characters long"); 91 | } 92 | 93 | const passwordResetToken = await VerificationToken.findOne({ userId }); 94 | 95 | if (!passwordResetToken) { 96 | res.status(400); 97 | throw new Error( 98 | "Your token is either invalid or expired. Try resetting your password again" 99 | ); 100 | } 101 | 102 | const user = await User.findById({ 103 | _id: passwordResetToken._userId, 104 | }).select("-passwordConfirm"); 105 | 106 | if (user && passwordResetToken) { 107 | user.password = password; 108 | await user.save(); 109 | 110 | const payload = { 111 | name: user.firstName, 112 | }; 113 | 114 | await sendEmail( 115 | user.email, 116 | "Password Reset Success", 117 | payload, 118 | "./emails/template/resetPassword.handlebars" 119 | ); 120 | 121 | res.json({ 122 | success: true, 123 | message: `Hey ${user.firstName},Your password reset was successful. An email has been sent to confirm the same`, 124 | }); 125 | } 126 | }); 127 | 128 | export { resetPasswordRequest, resetPassword }; 129 | -------------------------------------------------------------------------------- /backend/controllers/auth/refreshTokenController.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import jwt from "jsonwebtoken"; 3 | import User from "../../models/userModel.js"; 4 | 5 | // $-title Get new access tokens from the refresh token 6 | // $-path GET /api/v1/auth/new_access_token 7 | // $-auth Public 8 | // we are rotating the refresh tokens, deleting the old ones, creating new ones and detecting token reuse 9 | 10 | const newAccessToken = asyncHandler(async (req, res) => { 11 | const cookies = req.cookies; 12 | 13 | if (!cookies?.jwt) { 14 | return res.sendStatus(401); 15 | } 16 | 17 | const refreshToken = cookies.jwt; 18 | 19 | const options = { 20 | httpOnly: true, 21 | maxAge: 24 * 60 * 60 * 1000, 22 | secure: true, 23 | sameSite: "None", 24 | }; 25 | res.clearCookie("jwt", options); 26 | 27 | const existingUser = await User.findOne({ refreshToken }).exec(); 28 | 29 | if (!existingUser) { 30 | jwt.verify( 31 | refreshToken, 32 | process.env.JWT_REFRESH_SECRET_KEY, 33 | async (err, decoded) => { 34 | if (err) { 35 | return res.sendStatus(403); 36 | } 37 | const hackedUser = await User.findOne({ 38 | _id: decoded.id, 39 | }).exec(); 40 | hackedUser.refreshToken = []; 41 | await hackedUser.save(); 42 | } 43 | ); 44 | return res.sendStatus(403); 45 | } 46 | 47 | const newRefreshTokenArray = existingUser.refreshToken.filter( 48 | (refT) => refT !== refreshToken 49 | ); 50 | 51 | jwt.verify( 52 | refreshToken, 53 | process.env.JWT_REFRESH_SECRET_KEY, 54 | async (err, decoded) => { 55 | if (err) { 56 | existingUser.refreshToken = [...newRefreshTokenArray]; 57 | await existingUser.save(); 58 | } 59 | 60 | if (err || existingUser._id.toString() !== decoded.id) { 61 | return res.sendStatus(403); 62 | } 63 | 64 | const accessToken = jwt.sign( 65 | { 66 | id: existingUser._id, 67 | roles: existingUser.roles, 68 | }, 69 | process.env.JWT_ACCESS_SECRET_KEY, 70 | { expiresIn: "10m" } 71 | ); 72 | 73 | const newRefreshToken = jwt.sign( 74 | { id: existingUser._id }, 75 | process.env.JWT_REFRESH_SECRET_KEY, 76 | { expiresIn: "1d" } 77 | ); 78 | 79 | existingUser.refreshToken = [ 80 | ...newRefreshTokenArray, 81 | newRefreshToken, 82 | ]; 83 | await existingUser.save(); 84 | 85 | const options = { 86 | httpOnly: true, 87 | maxAge: 24 * 60 * 60 * 1000, 88 | secure: true, 89 | sameSite: "None", 90 | }; 91 | 92 | res.cookie("jwt", newRefreshToken, options); 93 | 94 | res.json({ 95 | success: true, 96 | firstName: existingUser.firstName, 97 | lastName: existingUser.lastName, 98 | username: existingUser.username, 99 | provider: existingUser.provider, 100 | avatar: existingUser.avatar, 101 | accessToken, 102 | }); 103 | } 104 | ); 105 | }); 106 | 107 | export default newAccessToken; 108 | -------------------------------------------------------------------------------- /backend/controllers/auth/registerController.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | import VerificationToken from "../../models/verifyResetTokenModel.js"; 4 | import sendEmail from "../../utils/sendEmail.js"; 5 | 6 | const domainURL = process.env.DOMAIN; 7 | 8 | const { randomBytes } = await import("crypto"); 9 | 10 | // $-title Register User and send email verification link 11 | // $-path POST /api/v1/auth/register 12 | // $-auth Public 13 | 14 | const registerUser = asyncHandler(async (req, res) => { 15 | const { email, username, firstName, lastName, password, passwordConfirm } = 16 | req.body; 17 | 18 | if (!email) { 19 | res.status(400); 20 | throw new Error("An email address is required"); 21 | } 22 | 23 | if (!username) { 24 | res.status(400); 25 | throw new Error("A username is required"); 26 | } 27 | if (!firstName || !lastName) { 28 | res.status(400); 29 | throw new Error( 30 | "You must enter a full name with a first and last name" 31 | ); 32 | } 33 | 34 | if (!password) { 35 | res.status(400); 36 | throw new Error("You must enter a password"); 37 | } 38 | if (!passwordConfirm) { 39 | res.status(400); 40 | throw new Error("Confirm password field is required"); 41 | } 42 | 43 | const userExists = await User.findOne({ email }); 44 | 45 | if (userExists) { 46 | res.status(400); 47 | throw new Error( 48 | "The email address you've entered is already associated with another account" 49 | ); 50 | } 51 | 52 | const newUser = new User({ 53 | email, 54 | username, 55 | firstName, 56 | lastName, 57 | password, 58 | passwordConfirm, 59 | }); 60 | 61 | const registeredUser = await newUser.save(); 62 | 63 | if (!registeredUser) { 64 | res.status(400); 65 | throw new Error("User could not be registered"); 66 | } 67 | 68 | if (registeredUser) { 69 | const verificationToken = randomBytes(32).toString("hex"); 70 | 71 | let emailVerificationToken = await new VerificationToken({ 72 | _userId: registeredUser._id, 73 | token: verificationToken, 74 | }).save(); 75 | 76 | const emailLink = `${domainURL}/api/v1/auth/verify/${emailVerificationToken.token}/${registeredUser._id}`; 77 | 78 | const payload = { 79 | name: registeredUser.firstName, 80 | link: emailLink, 81 | }; 82 | 83 | await sendEmail( 84 | registeredUser.email, 85 | "Account Verification", 86 | payload, 87 | "./emails/template/accountVerification.handlebars" 88 | ); 89 | 90 | res.json({ 91 | success: true, 92 | message: `A new user ${registeredUser.firstName} has been registered! A Verification email has been sent to your account. Please verify within 15 minutes`, 93 | }); 94 | } 95 | }); 96 | 97 | export default registerUser; 98 | -------------------------------------------------------------------------------- /backend/controllers/auth/resendVerifyEmailController.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | import VerificationToken from "../../models/verifyResetTokenModel.js"; 4 | import sendEmail from "../../utils/sendEmail.js"; 5 | const domainURL = process.env.DOMAIN; 6 | const { randomBytes } = await import("crypto"); 7 | 8 | // $-title Resend Email Verification Tokens 9 | // $-path POST /api/v1/auth/resend_email_token 10 | // $-auth Public 11 | 12 | const resendEmailVerificationToken = asyncHandler(async (req, res) => { 13 | const { email } = req.body; 14 | 15 | const user = await User.findOne({ email }); 16 | 17 | if (!email) { 18 | res.status(400); 19 | throw new Error("An email must be provided"); 20 | } 21 | 22 | if (!user) { 23 | res.status(400); 24 | throw new Error( 25 | "We were unable to find a user with that email address" 26 | ); 27 | } 28 | 29 | if (user.isEmailVerified) { 30 | res.status(400); 31 | throw new Error("This account has already been verified. Please login"); 32 | } 33 | 34 | let verificationToken = await VerificationToken.findOne({ 35 | _userId: user._id, 36 | }); 37 | 38 | if (verificationToken) { 39 | await VerificationToken.deleteOne(); 40 | } 41 | 42 | const resentToken = randomBytes(32).toString("hex"); 43 | 44 | let emailToken = await new VerificationToken({ 45 | _userId: user._id, 46 | token: resentToken, 47 | }).save(); 48 | 49 | const emailLink = `${domainURL}/api/v1/auth/verify/${emailToken.token}/${user._id}`; 50 | 51 | const payload = { 52 | name: user.firstName, 53 | link: emailLink, 54 | }; 55 | 56 | await sendEmail( 57 | user.email, 58 | "Account Verification", 59 | payload, 60 | "./emails/template/accountVerification.handlebars" 61 | ); 62 | 63 | res.json({ 64 | success: true, 65 | message: `${user.firstName}, an email has been sent to your account, please verify within 15 minutes`, 66 | }); 67 | }); 68 | 69 | export default resendEmailVerificationToken; 70 | -------------------------------------------------------------------------------- /backend/controllers/auth/verifyEmailController.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | import VerificationToken from "../../models/verifyResetTokenModel.js"; 4 | import sendEmail from "../../utils/sendEmail.js"; 5 | 6 | const domainURL = process.env.DOMAIN; 7 | 8 | // $-title Verify User Email 9 | // $-path GET /api/v1/auth/verify/:emailToken/:userId 10 | // $-auth Public 11 | 12 | const verifyUserEmail = asyncHandler(async (req, res) => { 13 | const user = await User.findOne({ _id: req.params.userId }).select( 14 | "-passwordConfirm" 15 | ); 16 | 17 | if (!user) { 18 | res.status(400); 19 | throw new Error("We were unable to find a user for this token"); 20 | } 21 | if (user.isEmailVerified) { 22 | res.status(400).send( 23 | "This user has already been verified. Please login" 24 | ); 25 | } 26 | 27 | const userToken = await VerificationToken.findOne({ 28 | _userId: user._id, 29 | token: req.params.emailToken, 30 | }); 31 | 32 | if (!userToken) { 33 | res.status(400); 34 | throw new Error("Token invalid! Your token may have expired"); 35 | } 36 | 37 | user.isEmailVerified = true; 38 | await user.save(); 39 | 40 | if (user.isEmailVerified) { 41 | const emailLink = `${domainURL}/login`; 42 | 43 | const payload = { 44 | name: user.firstName, 45 | link: emailLink, 46 | }; 47 | 48 | await sendEmail( 49 | user.email, 50 | "Welcome - Account Verified", 51 | payload, 52 | "./emails/template/welcome.handlebars" 53 | ); 54 | 55 | res.redirect("/auth/verify"); 56 | } 57 | }); 58 | 59 | export default verifyUserEmail; 60 | -------------------------------------------------------------------------------- /backend/controllers/customers/createCustomer.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Customer from "../../models/customerModel.js"; 3 | 4 | // $-title Create Customer 5 | // $-path POST /api/v1/customer/create 6 | // $-auth Private 7 | 8 | const createCustomer = asyncHandler(async (req, res) => { 9 | const { email, name, phoneNumber, vatTinNo, address, city, country } = 10 | req.body; 11 | 12 | if (!email || !name || !phoneNumber) { 13 | res.status(400); 14 | throw new Error( 15 | "A Customer must have at least a name, email and phone number" 16 | ); 17 | } 18 | 19 | const customerExists = await Customer.findOne({ email }); 20 | 21 | if (customerExists) { 22 | res.status(400); 23 | throw new Error("That Customer already exists"); 24 | } 25 | 26 | const newCustomer = new Customer({ 27 | createdBy: req.user._id, 28 | name, 29 | email, 30 | phoneNumber, 31 | vatTinNo, 32 | address, 33 | city, 34 | country, 35 | }); 36 | 37 | const createdCustomer = await newCustomer.save(); 38 | 39 | if (!createdCustomer) { 40 | res.status(400); 41 | throw new Error("Customer could not be created"); 42 | } 43 | 44 | res.status(200).json({ 45 | success: true, 46 | message: `Your customer named: ${createdCustomer.name}, was created successfully`, 47 | createdCustomer, 48 | }); 49 | }); 50 | 51 | export default createCustomer; 52 | -------------------------------------------------------------------------------- /backend/controllers/customers/deleteCustomer.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Customer from "../../models/customerModel.js"; 3 | 4 | // $-title Delete Customer 5 | // $-path DELETE /api/v1/customer/:id 6 | // $-auth Private 7 | 8 | const deleteCustomer = asyncHandler(async (req, res) => { 9 | const customer = await Customer.findById(req.params.id); 10 | 11 | if (!customer) { 12 | res.status(404); 13 | throw new Error("That customer does not exist!"); 14 | } 15 | 16 | if (customer.createdBy.toString() !== req.user.id) { 17 | res.status(401); 18 | throw new Error( 19 | "You are not authorized to delete this customer's information. He/She is not your customer!" 20 | ); 21 | } 22 | 23 | await customer.delete(); 24 | 25 | res.json({ success: true, message: "Your customer has been deleted" }); 26 | }); 27 | 28 | export default deleteCustomer; 29 | -------------------------------------------------------------------------------- /backend/controllers/customers/getAllUserCustomers.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Customer from "../../models/customerModel.js"; 3 | 4 | // $-title Get all customers belonging to a specific User 5 | // $-path GET /api/v1/customer/all 6 | // $-auth Private 7 | 8 | const getAllUserCustomers = asyncHandler(async (req, res) => { 9 | const pageSize = 10; 10 | const page = Number(req.query.page) || 1; 11 | 12 | const count = await Customer.countDocuments({ createdBy: req.user._id }); 13 | 14 | const customers = await Customer.find({ createdBy: req.user._id }) 15 | .sort({ 16 | createdAt: -1, 17 | }) 18 | .limit(pageSize) 19 | .skip(pageSize * (page - 1)) 20 | .lean(); 21 | 22 | res.json({ 23 | success: true, 24 | totalCustomers: count, 25 | numberOfPages: Math.ceil(count / pageSize), 26 | myCustomers: customers, 27 | }); 28 | }); 29 | 30 | export default getAllUserCustomers; 31 | -------------------------------------------------------------------------------- /backend/controllers/customers/getSingleUserCustomer.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Customer from "../../models/customerModel.js"; 3 | 4 | // $-title Get a Single customer belonging to a User 5 | // $-path GET /api/v1/customer/:id 6 | // $-auth Private 7 | 8 | const getSingleUserCustomer = asyncHandler(async (req, res) => { 9 | const customer = await Customer.findById(req.params.id); 10 | 11 | const user = req.user._id; 12 | 13 | if (!customer) { 14 | res.status(204); 15 | throw new Error("Customer not found"); 16 | } 17 | 18 | if (customer.id !== user) { 19 | res.status(200).json({ 20 | success: true, 21 | customer, 22 | }); 23 | } else { 24 | res.status(401); 25 | throw new Error( 26 | "You are not authorized to view this customer's information. He/She is not your customer" 27 | ); 28 | } 29 | }); 30 | 31 | export default getSingleUserCustomer; 32 | -------------------------------------------------------------------------------- /backend/controllers/customers/updateCustomerInfo.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Customer from "../../models/customerModel.js"; 3 | 4 | // $-title Update Customer 5 | // $-path PATCH /api/v1/customer/:id 6 | // $-auth Private 7 | 8 | const updateCustomerInfo = asyncHandler(async (req, res) => { 9 | const customer = await Customer.findById(req.params.id); 10 | 11 | if (!customer) { 12 | res.status(404); 13 | throw new Error("That Customer does not exist"); 14 | } 15 | 16 | if (customer.createdBy.toString() !== req.user.id) { 17 | res.status(401); 18 | throw new Error( 19 | "You are not authorized to update this customer's information. He/She is not your customer" 20 | ); 21 | } 22 | 23 | const { id: _id } = req.params; 24 | const fieldsToUpdate = req.body; 25 | 26 | const updatedCustomerInfo = await Customer.findByIdAndUpdate( 27 | _id, 28 | { ...fieldsToUpdate, _id }, 29 | { new: true, runValidators: true } 30 | ); 31 | 32 | res.status(200).json({ 33 | success: true, 34 | message: `${customer.name}'s info was successfully updated`, 35 | updatedCustomerInfo, 36 | }); 37 | }); 38 | 39 | export default updateCustomerInfo; 40 | -------------------------------------------------------------------------------- /backend/controllers/documents/createDocument.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Customer from "../../models/customerModel.js"; 3 | import Document from "../../models/documentModel.js"; 4 | 5 | // $-title Create Document 6 | // $-path POST /api/v1/document/create 7 | // $-auth Private 8 | 9 | const createDocument = asyncHandler(async (req, res) => { 10 | const customer = await Customer.findOne({ createdBy: req.user._id }); 11 | 12 | if (!customer) { 13 | res.status(404); 14 | throw new Error( 15 | "That customer does not exist for the currently logged in user" 16 | ); 17 | } 18 | 19 | if (customer.createdBy.toString() !== req.user._id.toString()) { 20 | res.status(400); 21 | throw new Error( 22 | "You are not allowed to create documents for customers who you did not create" 23 | ); 24 | } 25 | 26 | const fieldsToCreate = req.body; 27 | 28 | const newDocument = new Document({ 29 | createdBy: req.user._id, 30 | ...fieldsToCreate, 31 | }); 32 | 33 | const createdDocument = await newDocument.save(); 34 | 35 | if (!createdDocument) { 36 | res.status(400); 37 | throw new Error("The document could not be created"); 38 | } 39 | 40 | res.status(200).json({ 41 | success: true, 42 | newDocument, 43 | }); 44 | }); 45 | 46 | export default createDocument; 47 | -------------------------------------------------------------------------------- /backend/controllers/documents/createPayment.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Document from "../../models/documentModel.js"; 3 | 4 | // $-title Create new payment 5 | // $-path POST /api/v1/document/:id/payment 6 | // $-auth Private 7 | 8 | const createDocumentPayment = asyncHandler(async (req, res) => { 9 | const document = await Document.findById(req.params.id); 10 | 11 | const { datePaid, amountPaid, paymentMethod, additionalInfo } = req.body; 12 | 13 | const payment = { 14 | paidBy: document.customer.name, 15 | datePaid, 16 | amountPaid, 17 | paymentMethod, 18 | additionalInfo, 19 | }; 20 | document.paymentRecords.push(payment); 21 | 22 | await document.save(); 23 | 24 | res.status(201).json({ 25 | success: true, 26 | message: "Payment has been recorded successfully", 27 | }); 28 | }); 29 | 30 | export default createDocumentPayment; 31 | -------------------------------------------------------------------------------- /backend/controllers/documents/deleteDocument.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Document from "../../models/documentModel.js"; 3 | 4 | // $-title Delete Document 5 | // $-path DELETE /api/v1/document/:id 6 | // $-auth Private 7 | 8 | const deleteDocument = asyncHandler(async (req, res) => { 9 | const document = await Document.findById(req.params.id); 10 | 11 | if (!document) { 12 | res.status(404); 13 | throw new Error("That document does not exist!"); 14 | } 15 | 16 | if (document.createdBy.toString() !== req.user.id) { 17 | res.status(401); 18 | throw new Error( 19 | "You are not authorized to delete this document. It's not yours" 20 | ); 21 | } 22 | 23 | await document.delete(); 24 | 25 | res.json({ success: true, message: "Your document has been deleted" }); 26 | }); 27 | 28 | export default deleteDocument; 29 | -------------------------------------------------------------------------------- /backend/controllers/documents/generatePDF.js: -------------------------------------------------------------------------------- 1 | import pdf from "html-pdf"; 2 | import path from "path"; 3 | import { fileURLToPath } from "url"; 4 | import transporter from "../../helpers/emailTransport.js"; 5 | import emailTemplate from "../../utils/pdf/emailTemplate.js"; 6 | import options from "../../utils/pdf/options.js"; 7 | import pdfTemplate from "../../utils/pdf/pdfTemplate.js"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | const filepath = path.join(__dirname, "../../../docs/myDocument.pdf"); 13 | 14 | // $-title Generate document 15 | // $-path POST /api/v1/document/generate-pdf 16 | // $-auth Public 17 | export const generatePDF = async (req, res) => { 18 | pdf.create(pdfTemplate(req.body), options).toFile( 19 | "myDocument.pdf", 20 | (err) => { 21 | if (err) { 22 | res.send(Promise.reject()); 23 | } 24 | res.send(Promise.resolve()); 25 | } 26 | ); 27 | }; 28 | 29 | // $-title Generate document 30 | // $-path GET /api/v1/document/get-pdf 31 | // $-auth Public 32 | export const getPDF = (req, res) => { 33 | res.sendFile(filepath); 34 | }; 35 | 36 | // $-title send document as email 37 | // $-path POST /api/v1/document/send-document 38 | // $-auth Public 39 | 40 | export const sendDocument = (req, res) => { 41 | const { profile, document } = req.body; 42 | 43 | pdf.create(pdfTemplate(req.body), options).toFile(filepath, (err) => { 44 | transporter.sendMail({ 45 | from: process.env.SENDER_EMAIL, 46 | to: `${document.customer.email}`, 47 | replyTo: `${profile.email}`, 48 | subject: `Document from ${ 49 | profile.businessName ? profile.businessName : profile.firstName 50 | }`, 51 | text: `Document from ${ 52 | profile.businessName ? profile.businessName : profile.firstName 53 | }`, 54 | html: emailTemplate(req.body), 55 | attachments: [ 56 | { 57 | filename: "myDocument.pdf", 58 | path: filepath, 59 | }, 60 | ], 61 | }); 62 | 63 | if (err) { 64 | res.send(Promise.reject()); 65 | } 66 | res.send(Promise.resolve()); 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /backend/controllers/documents/getAllUserDocuments.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Document from "../../models/documentModel.js"; 3 | 4 | // $-title Get all documents belonging to a specific User 5 | // $-path GET /api/v1/document/all 6 | // $-auth Private 7 | 8 | const getAllUserDocuments = asyncHandler(async (req, res) => { 9 | const pageSize = 10; 10 | const page = Number(req.query.page) || 1; 11 | 12 | const count = await Document.countDocuments({ createdBy: req.user._id }); 13 | 14 | const documents = await Document.find({ createdBy: req.user._id }) 15 | .sort({ 16 | createdAt: -1, 17 | }) 18 | .limit(pageSize) 19 | .skip(pageSize * (page - 1)) 20 | .lean(); 21 | 22 | res.json({ 23 | success: true, 24 | totalDocuments: count, 25 | numberOfPages: Math.ceil(count / pageSize), 26 | myDocuments: documents, 27 | }); 28 | }); 29 | 30 | export default getAllUserDocuments; 31 | -------------------------------------------------------------------------------- /backend/controllers/documents/getSingleUserDocument.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Document from "../../models/documentModel.js"; 3 | 4 | // $-title Get a Single Document belonging to a User 5 | // $-path GET /api/v1/document/:id 6 | // $-auth Private 7 | 8 | const getSingleUserDocument = asyncHandler(async (req, res) => { 9 | const document = await Document.findById(req.params.id); 10 | 11 | const user = req.user._id; 12 | 13 | if (!document) { 14 | res.status(204); 15 | throw new Error("document not found"); 16 | } 17 | 18 | if (document.id !== user) { 19 | res.status(200).json({ 20 | success: true, 21 | document, 22 | }); 23 | } else { 24 | res.status(401); 25 | throw new Error( 26 | "You are not authorized to view this document. It's not yours" 27 | ); 28 | } 29 | }); 30 | 31 | export default getSingleUserDocument; 32 | -------------------------------------------------------------------------------- /backend/controllers/documents/updateDocument.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import Document from "../../models/documentModel.js"; 3 | 4 | // $-title Update Document 5 | // $-path PATCH /api/v1/document/:id 6 | // $-auth Private 7 | 8 | const updateDocument = asyncHandler(async (req, res) => { 9 | const document = await Document.findById(req.params.id); 10 | 11 | if (!document) { 12 | res.status(404); 13 | throw new Error("That document does not exist"); 14 | } 15 | 16 | if (document.createdBy.toString() !== req.user.id) { 17 | res.status(401); 18 | throw new Error( 19 | "You are not authorized to update this document. It's not yours" 20 | ); 21 | } 22 | 23 | const updatedDocument = await Document.findByIdAndUpdate( 24 | req.params.id, 25 | req.body, 26 | { new: true, runValidators: true } 27 | ); 28 | 29 | res.status(200).json({ 30 | success: true, 31 | message: `Your ${updatedDocument.documentType}'s info was updated successfully`, 32 | updatedDocument, 33 | }); 34 | }); 35 | 36 | export default updateDocument; 37 | -------------------------------------------------------------------------------- /backend/controllers/user/deactivateUser.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | 4 | // $-title Deactivate user account 5 | // $-path PATCH /api/v1/user/:id/deactivate 6 | // $-auth Private/Admin 7 | const deactivateUser = asyncHandler(async (req, res) => { 8 | const user = await User.findById(req.params.id); 9 | 10 | if (user) { 11 | user.active = false; 12 | 13 | const updatedUser = await user.save(); 14 | 15 | res.json(updatedUser); 16 | } else { 17 | res.status(404); 18 | throw new Error("user was not found"); 19 | } 20 | }); 21 | 22 | export default deactivateUser; 23 | -------------------------------------------------------------------------------- /backend/controllers/user/deleteMyAccount.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | 4 | // $-title Delete My Account 5 | // $-path DELETE /api/v1/user/profile 6 | // $-auth Private 7 | const deleteMyAccount = asyncHandler(async (req, res) => { 8 | const userId = req.user._id; 9 | 10 | await User.findByIdAndDelete(userId); 11 | 12 | res.json({ success: true, message: "Your user account has been deleted" }); 13 | }); 14 | 15 | export default deleteMyAccount; 16 | -------------------------------------------------------------------------------- /backend/controllers/user/deleteUserAccount.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | 4 | // $-title Delete User Account 5 | // $-path DELETE /api/v1/user/:id 6 | // $-auth Private/Admin 7 | // an admin user can delete any other user account 8 | const deleteUserAccount = asyncHandler(async (req, res) => { 9 | const user = await User.findById(req.params.id); 10 | 11 | if (user) { 12 | const result = await user.remove(); 13 | 14 | res.json({ 15 | success: true, 16 | message: `User ${result.firstName} deleted successfully`, 17 | }); 18 | } else { 19 | res.status(404); 20 | throw new Error("user not found"); 21 | } 22 | }); 23 | 24 | export default deleteUserAccount; 25 | -------------------------------------------------------------------------------- /backend/controllers/user/getAllUserAccounts.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | 4 | // $-title Get All Users 5 | // $-path GET /api/v1/user/all 6 | // $-auth Private/Admin 7 | const getAllUserAccounts = asyncHandler(async (req, res) => { 8 | const pageSize = 10; 9 | 10 | const page = Number(req.query.pageNumber) || 1; 11 | 12 | const count = await User.countDocuments({}); 13 | 14 | const users = await User.find() 15 | .sort({ createdAt: -1 }) 16 | .select("-refreshToken") 17 | .limit(pageSize) 18 | .skip(pageSize * (page - 1)) 19 | .lean(); 20 | 21 | res.json({ 22 | success: true, 23 | count, 24 | numberOfPages: Math.ceil(count / pageSize), 25 | users, 26 | }); 27 | }); 28 | 29 | export default getAllUserAccounts; 30 | -------------------------------------------------------------------------------- /backend/controllers/user/getUserProfile.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | 4 | // $-title Get User Profile 5 | // $-path GET /api/v1/user/profile 6 | // $-auth Private 7 | 8 | const getUserProfile = asyncHandler(async (req, res) => { 9 | const userId = req.user._id; 10 | 11 | const userProfile = await User.findById(userId, { 12 | refreshToken: 0, 13 | roles: 0, 14 | _id: 0, 15 | }).lean(); 16 | 17 | if (!userProfile) { 18 | res.status(204); 19 | throw new Error("user profile not found"); 20 | } 21 | 22 | res.status(200).json({ 23 | success: true, 24 | userProfile, 25 | }); 26 | }); 27 | 28 | export default getUserProfile; 29 | -------------------------------------------------------------------------------- /backend/controllers/user/updateUserProfile.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import User from "../../models/userModel.js"; 3 | 4 | // $-title Update User Profile 5 | // $-path PATCH /api/v1/user/profile 6 | // $-auth Private 7 | 8 | const updateUserProfile = asyncHandler(async (req, res) => { 9 | const userId = req.user._id; 10 | 11 | const { 12 | password, 13 | passwordConfirm, 14 | email, 15 | isEmailVerified, 16 | provider, 17 | roles, 18 | googleID, 19 | username, 20 | } = req.body; 21 | 22 | const user = await User.findById(userId); 23 | 24 | if (!user) { 25 | res.status(400); 26 | throw new Error("That user does not exist in our system"); 27 | } 28 | 29 | if (password || passwordConfirm) { 30 | res.status(400); 31 | throw new Error( 32 | "This route is not for password updates. Please use the password reset functionality instead" 33 | ); 34 | } 35 | 36 | if (email || isEmailVerified || provider || roles || googleID) { 37 | res.status(400); 38 | throw new Error( 39 | "You are not allowed to update that field on this route" 40 | ); 41 | } 42 | 43 | const fieldsToUpdate = req.body; 44 | 45 | const updatedProfile = await User.findByIdAndUpdate( 46 | userId, 47 | { ...fieldsToUpdate }, 48 | { new: true, runValidators: true } 49 | ).select("-refreshToken"); 50 | 51 | res.status(200).json({ 52 | success: true, 53 | message: `${user.firstName}, your profile was successfully updated`, 54 | updatedProfile, 55 | }); 56 | }); 57 | 58 | export default updateUserProfile; 59 | -------------------------------------------------------------------------------- /backend/helpers/emailTransport.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import nodemailer from "nodemailer"; 3 | import mg from "nodemailer-mailgun-transport"; 4 | 5 | let transporter; 6 | 7 | if (process.env.NODE_ENV === "development") { 8 | transporter = nodemailer.createTransport({ 9 | host: "mailhog", 10 | port: 1025, 11 | }); 12 | } else if (process.env.NODE_ENV === "production") { 13 | const mailgunAuth = { 14 | auth: { 15 | api_key: process.env.MAILGUN_API_KEY, 16 | domain: process.env.MAILGUN_DOMAIN, 17 | }, 18 | }; 19 | transporter = nodemailer.createTransport(mg(mailgunAuth)); 20 | } 21 | 22 | export default transporter; 23 | -------------------------------------------------------------------------------- /backend/helpers/multer.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import multer from "multer"; 3 | import path from "path"; 4 | 5 | if (!fs.existsSync("./uploads")) { 6 | fs.mkdirSync("./uploads"); 7 | } 8 | 9 | const storage = multer.diskStorage({ 10 | destination: function (req, file, cb) { 11 | cb(null, "./uploads"); 12 | }, 13 | filename: function (req, file, cb) { 14 | cb( 15 | null, 16 | `${file.filename}-${Date.now()}${path.extname(file.originalname)}` 17 | ); 18 | }, 19 | }); 20 | 21 | function checkImageType(file, cb) { 22 | const filetypes = /jpeg|jpg|png/; 23 | const extname = filetypes.test( 24 | path.extname(file.originalname).toLowerCase() 25 | ); 26 | 27 | const mimetype = filetypes.test(file.mimetype); 28 | 29 | if (extname && mimetype) { 30 | return cb(null, true); 31 | } else { 32 | cb("Unsupported file format. You can only upload jpeg, jpg and png"); 33 | } 34 | } 35 | 36 | const upload = multer({ 37 | storage, 38 | limits: { fileSize: 1024 * 1024 }, 39 | fileFilter: function (req, file, cb) { 40 | checkImageType(file, cb); 41 | }, 42 | }); 43 | 44 | export default upload; 45 | -------------------------------------------------------------------------------- /backend/middleware/apiLimiter.js: -------------------------------------------------------------------------------- 1 | import rateLimit from "express-rate-limit"; 2 | import { systemLogs } from "../utils/Logger.js"; 3 | 4 | export const apiLimiter = rateLimit({ 5 | windowMs: 15 * 60 * 1000, 6 | max: 100, 7 | message: { 8 | message: 9 | "Too many requests from this IP address, please try again after 15 minmutes", 10 | }, 11 | handler: (req, res, next, options) => { 12 | systemLogs.error( 13 | `Too many requests: ${options.message.message}\t${req.method}\t${req.url}\t${req.headers.origin}` 14 | ); 15 | res.status(options.statusCode).send(options.message); 16 | }, 17 | standardHeaders: true, 18 | legacyHeaders: false, 19 | }); 20 | 21 | export const loginLimiter = rateLimit({ 22 | windowMs: 30 * 60 * 1000, 23 | max: 20, 24 | message: { 25 | message: 26 | "Too many login attempts from this IP address, please try again after 30 minutes", 27 | }, 28 | handler: (req, res, next, options) => { 29 | systemLogs.error( 30 | `Too many requests: ${options.message.message}\t${req.method}\t${req.url}\t${req.headers.origin}` 31 | ); 32 | res.status(options.statusCode).send(options.message); 33 | }, 34 | standardHeaders: true, 35 | legacyHeaders: false, 36 | }); 37 | -------------------------------------------------------------------------------- /backend/middleware/checkAuthMiddleware.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from "express-async-handler"; 2 | import jwt from "jsonwebtoken"; 3 | import User from "../models/userModel.js"; 4 | 5 | const checkAuth = asyncHandler(async (req, res, next) => { 6 | let jwt_token; 7 | 8 | // Bearer sdfasdfasdfasdfsd 9 | 10 | const authHeader = req.headers.authorization || req.headers.Authorization; 11 | 12 | if (!authHeader?.startsWith("Bearer")) return res.sendStatus(401); 13 | 14 | if (authHeader && authHeader.startsWith("Bearer")) { 15 | jwt_token = authHeader.split(" ")[1]; 16 | 17 | jwt.verify( 18 | jwt_token, 19 | process.env.JWT_ACCESS_SECRET_KEY, 20 | async (err, decoded) => { 21 | if (err) return res.sendStatus(403); 22 | 23 | const userId = decoded.id; 24 | req.user = await User.findById(userId).select("-password"); 25 | req.roles = decoded.roles; 26 | next(); 27 | } 28 | ); 29 | } 30 | }); 31 | 32 | export default checkAuth; 33 | -------------------------------------------------------------------------------- /backend/middleware/errorMiddleware.js: -------------------------------------------------------------------------------- 1 | const errorHandler = (err, req, res, next) => { 2 | const statusCode = res.statusCode ? res.statusCode : 500; 3 | 4 | return res.status(statusCode).json({ 5 | success: false, 6 | message: err.message, 7 | statusCode, 8 | stack: process.env.NODE_ENV === "production" ? null : err.stack, 9 | }); 10 | }; 11 | 12 | const notFound = (req, res, next) => { 13 | const error = new Error(`That route does not exist - ${req.originalUrl}`); 14 | res.status(404); 15 | next(error); 16 | }; 17 | 18 | export { errorHandler, notFound }; 19 | -------------------------------------------------------------------------------- /backend/middleware/roleMiddleware.js: -------------------------------------------------------------------------------- 1 | import { ADMIN, USER } from "../constants/index.js"; 2 | 3 | const ROLES = { 4 | User: USER, 5 | Admin: ADMIN, 6 | }; 7 | 8 | const checkRole = (...allowedRoles) => { 9 | return (req, res, next) => { 10 | if (!req?.user && !req?.roles) { 11 | res.status(401); 12 | throw new Error("You are not authorized to use our platform"); 13 | } 14 | 15 | const rolesArray = [...allowedRoles]; 16 | 17 | const roleFound = req.roles 18 | .map((role) => rolesArray.includes(role)) 19 | .find((value) => value === true); 20 | 21 | if (!roleFound) { 22 | res.status(401); 23 | throw new Error("You are not authorized to perform this request"); 24 | } 25 | 26 | next(); 27 | }; 28 | }; 29 | 30 | const role = { ROLES, checkRole }; 31 | 32 | export default role; 33 | -------------------------------------------------------------------------------- /backend/models/customerModel.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import validator from "validator"; 3 | 4 | const { randomBytes } = await import("crypto"); 5 | 6 | const { Schema } = mongoose; 7 | 8 | const customerSchema = new Schema( 9 | { 10 | createdBy: { 11 | type: Schema.Types.ObjectId, 12 | required: true, 13 | ref: "User", 14 | }, 15 | name: { 16 | type: String, 17 | required: true, 18 | trim: true, 19 | }, 20 | email: { 21 | type: String, 22 | required: true, 23 | lowercase: true, 24 | unique: true, 25 | validate: [ 26 | validator.isEmail, 27 | "A customer must have a valid email address", 28 | ], 29 | }, 30 | accountNo: String, 31 | vatTinNo: { 32 | type: Number, 33 | default: 0, 34 | }, 35 | address: String, 36 | city: String, 37 | country: String, 38 | phoneNumber: { 39 | type: String, 40 | required: true, 41 | validate: [ 42 | validator.isMobilePhone, 43 | "Your mobile phone number must begin with a '+', followed by your country code then actual number e.g +254123456789", 44 | ], 45 | }, 46 | }, 47 | { 48 | timestamps: true, 49 | toJSON: { virtuals: true }, 50 | toObject: { virtuals: true }, 51 | } 52 | ); 53 | 54 | customerSchema.pre("save", async function (next) { 55 | this.accountNo = `CUS-${randomBytes(3).toString("hex").toUpperCase()}`; 56 | 57 | next(); 58 | }); 59 | 60 | const Customer = mongoose.model("Customer", customerSchema); 61 | 62 | export default Customer; 63 | -------------------------------------------------------------------------------- /backend/models/documentModel.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const { randomBytes } = await import("crypto"); 4 | 5 | const { Schema } = mongoose; 6 | 7 | const paymentSchema = new Schema( 8 | { 9 | paidBy: String, 10 | datePaid: String, 11 | amountPaid: Number, 12 | paymentMethod: { 13 | type: String, 14 | default: "Cash", 15 | enum: [ 16 | "Cash", 17 | "Mobile Money", 18 | "PayPal", 19 | "Credit Card", 20 | "Bank Transfer", 21 | "Others", 22 | ], 23 | }, 24 | additionalInfo: String, 25 | }, 26 | { 27 | timestamps: true, 28 | } 29 | ); 30 | 31 | const documentSchema = new Schema( 32 | { 33 | createdBy: { 34 | type: Schema.Types.ObjectId, 35 | required: true, 36 | ref: "User", 37 | }, 38 | customer: { 39 | name: String, 40 | email: String, 41 | accountNo: String, 42 | vatTinNo: String, 43 | address: String, 44 | city: String, 45 | country: String, 46 | phoneNumber: String, 47 | }, 48 | documentType: { 49 | type: String, 50 | default: "Invoice", 51 | enum: ["Invoice", "Receipt", "Quotation"], 52 | }, 53 | documentNumber: String, 54 | dueDate: Date, 55 | additionalInfo: String, 56 | termsConditions: String, 57 | status: { 58 | type: String, 59 | default: "Not Paid", 60 | enum: ["Paid", "Not Fully Paid", "Not Paid"], 61 | }, 62 | subTotal: Number, 63 | salesTax: Number, 64 | rates: String, 65 | total: Number, 66 | currency: String, 67 | totalAmountReceived: Number, 68 | billingItems: [ 69 | { 70 | itemName: String, 71 | unitPrice: Number, 72 | quantity: Number, 73 | discount: String, 74 | }, 75 | ], 76 | paymentRecords: [paymentSchema], 77 | }, 78 | { 79 | timestamps: true, 80 | } 81 | ); 82 | 83 | documentSchema.pre("save", async function (next) { 84 | this.documentNumber = `${new Date().getFullYear()}-${new Date().toLocaleString( 85 | "default", 86 | { month: "long" } 87 | )}-${randomBytes(3).toString("hex").toUpperCase()}`; 88 | next(); 89 | }); 90 | 91 | const Document = mongoose.model("Document", documentSchema); 92 | 93 | export default Document; 94 | -------------------------------------------------------------------------------- /backend/models/userModel.js: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | import "dotenv/config"; 3 | import mongoose from "mongoose"; 4 | import validator from "validator"; 5 | import { USER } from "../constants/index.js"; 6 | 7 | const { Schema } = mongoose; 8 | 9 | const userSchema = new Schema( 10 | { 11 | email: { 12 | type: String, 13 | lowercase: true, 14 | unique: true, 15 | required: true, 16 | validate: [validator.isEmail, "Please provide a valid email"], 17 | }, 18 | 19 | username: { 20 | type: String, 21 | required: true, 22 | unique: true, 23 | trim: true, 24 | validate: { 25 | validator: function (value) { 26 | return /^[A-z][A-z0-9-_]{3,23}$/.test(value); 27 | }, 28 | message: 29 | "username must be alphanumeric,without special characters.Hyphens and underscores allowed", 30 | }, 31 | }, 32 | 33 | firstName: { 34 | type: String, 35 | required: true, 36 | trim: true, 37 | validate: [ 38 | validator.isAlphanumeric, 39 | "First Name can only have Alphanumeric values. No special characters allowed", 40 | ], 41 | }, 42 | 43 | lastName: { 44 | type: String, 45 | required: true, 46 | trim: true, 47 | validate: [ 48 | validator.isAlphanumeric, 49 | "Last Name can only have Alphanumeric values. No special characters allowed", 50 | ], 51 | }, 52 | password: { 53 | type: String, 54 | select: false, 55 | validate: [ 56 | validator.isStrongPassword, 57 | "Password must be at least 8 characters long, with at least 1 uppercase and lowercase letters and at least 1 symbol", 58 | ], 59 | }, 60 | passwordConfirm: { 61 | type: String, 62 | validate: { 63 | validator: function (value) { 64 | return value === this.password; 65 | }, 66 | message: "Passwords do not match", 67 | }, 68 | }, 69 | isEmailVerified: { type: Boolean, required: true, default: false }, 70 | provider: { 71 | type: String, 72 | required: true, 73 | default: "email", 74 | }, 75 | googleID: String, 76 | avatar: String, 77 | businessName: String, 78 | phoneNumber: { 79 | type: String, 80 | default: "+254123456789", 81 | validate: [ 82 | validator.isMobilePhone, 83 | "Your mobile phone number must begin with a '+', followed by your country code then actual number e.g +254123456789", 84 | ], 85 | }, 86 | address: String, 87 | city: String, 88 | country: String, 89 | passwordChangedAt: Date, 90 | 91 | roles: { 92 | type: [String], 93 | default: [USER], 94 | }, 95 | active: { 96 | type: Boolean, 97 | default: true, 98 | }, 99 | refreshToken: [String], 100 | }, 101 | { 102 | timestamps: true, 103 | } 104 | ); 105 | 106 | userSchema.pre("save", async function (next) { 107 | if (this.roles.length === 0) { 108 | this.roles.push(USER); 109 | next(); 110 | } 111 | }); 112 | 113 | userSchema.pre("save", async function (next) { 114 | if (!this.isModified("password")) { 115 | return next(); 116 | } 117 | 118 | const salt = await bcrypt.genSalt(10); 119 | this.password = await bcrypt.hash(this.password, salt); 120 | 121 | this.passwordConfirm = undefined; 122 | next(); 123 | }); 124 | 125 | userSchema.pre("save", async function (next) { 126 | if (!this.isModified("password") || this.isNew) { 127 | return next(); 128 | } 129 | 130 | this.passwordChangedAt = Date.now(); 131 | next(); 132 | }); 133 | 134 | userSchema.methods.comparePassword = async function (givenPassword) { 135 | return await bcrypt.compare(givenPassword, this.password); 136 | }; 137 | 138 | const User = mongoose.model("User", userSchema); 139 | 140 | export default User; 141 | -------------------------------------------------------------------------------- /backend/models/verifyResetTokenModel.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const { Schema } = mongoose; 4 | 5 | const verifyResetTokenSchema = new Schema({ 6 | _userId: { 7 | type: mongoose.Schema.Types.ObjectId, 8 | required: true, 9 | ref: "User", 10 | }, 11 | token: { type: String, required: true }, 12 | createdAt: { 13 | type: Date, 14 | required: true, 15 | default: Date.now, 16 | expires: 900, 17 | }, 18 | }); 19 | 20 | const VerifyResetToken = mongoose.model( 21 | "VerifyResetToken", 22 | verifyResetTokenSchema 23 | ); 24 | 25 | export default VerifyResetToken; 26 | -------------------------------------------------------------------------------- /backend/routes/authRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import registerUser from "../controllers/auth/registerController.js"; 3 | import verifyUserEmail from "../controllers/auth/verifyEmailController.js"; 4 | import loginUser from "../controllers/auth/loginController.js"; 5 | import { loginLimiter } from "../middleware/apiLimiter.js"; 6 | import newAccessToken from "../controllers/auth/refreshTokenController.js"; 7 | import resendEmailVerificationToken from "../controllers/auth/resendVerifyEmailController.js"; 8 | import logoutUser from "../controllers/auth/logoutController.js"; 9 | import passport from "passport"; 10 | import jwt from "jsonwebtoken"; 11 | import User from "../models/userModel.js"; 12 | 13 | import { 14 | resetPassword, 15 | resetPasswordRequest, 16 | } from "../controllers/auth/passwordResetController.js"; 17 | 18 | const router = express.Router(); 19 | 20 | router.post("/register", registerUser); 21 | 22 | router.get("/verify/:emailToken/:userId", verifyUserEmail); 23 | 24 | router.post("/login", loginLimiter, loginUser); 25 | 26 | router.get("/new_access_token", newAccessToken); 27 | 28 | router.post("/resend_email_token", resendEmailVerificationToken); 29 | 30 | router.post("/reset_password_request", resetPasswordRequest); 31 | 32 | router.post("/reset_password", resetPassword); 33 | 34 | router.get("/logout", logoutUser); 35 | 36 | router.get( 37 | "/google", 38 | passport.authenticate("google", { 39 | session: false, 40 | scope: ["profile", "email"], 41 | accessType: "offline", 42 | prompt: "consent", 43 | }) 44 | ); 45 | 46 | // $-title Redirect route to the passport google strategy 47 | // $-path GET /api/v1/auth/google/redirect 48 | router.get( 49 | "/google/redirect", 50 | passport.authenticate("google", { 51 | failureRedirect: "/login", 52 | session: false, 53 | }), 54 | 55 | async (req, res) => { 56 | const existingUser = await User.findById(req.user.id); 57 | 58 | const payload = { 59 | id: req.user.id, 60 | roles: existingUser.roles, 61 | firstName: existingUser.firstName, 62 | lastName: existingUser.lastName, 63 | username: existingUser.username, 64 | provider: existingUser.provider, 65 | avatar: existingUser.avatar, 66 | }; 67 | 68 | jwt.sign( 69 | payload, 70 | process.env.JWT_ACCESS_SECRET_KEY, 71 | { expiresIn: "20m" }, 72 | (err, token) => { 73 | const jwt = `${token}`; 74 | 75 | const embedJWT = ` 76 | 77 | 81 | 82 | 83 | 84 | `; 85 | res.send(embedJWT); 86 | } 87 | ); 88 | } 89 | ); 90 | 91 | export default router; 92 | -------------------------------------------------------------------------------- /backend/routes/customerRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | import createCustomer from "../controllers/customers/createCustomer.js"; 4 | import deleteCustomer from "../controllers/customers/deleteCustomer.js"; 5 | import getAllUserCustomers from "../controllers/customers/getAllUserCustomers.js"; 6 | import getSingleUserCustomer from "../controllers/customers/getSingleUserCustomer.js"; 7 | import updateCustomerInfo from "../controllers/customers/updateCustomerInfo.js"; 8 | import checkAuth from "../middleware/checkAuthMiddleware.js"; 9 | 10 | const router = express.Router(); 11 | 12 | // create a new customer at /api/v1/customer/create 13 | router.route("/create").post(checkAuth, createCustomer); 14 | 15 | // get all of a users customers at /api/v1/customer/all 16 | router.route("/all").get(checkAuth, getAllUserCustomers); 17 | 18 | // get, update and delete a customer 19 | router 20 | .route("/:id") 21 | .get(checkAuth, getSingleUserCustomer) 22 | .patch(checkAuth, updateCustomerInfo) 23 | .delete(checkAuth, deleteCustomer); 24 | 25 | export default router; 26 | -------------------------------------------------------------------------------- /backend/routes/documentRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import createDocument from "../controllers/documents/createDocument.js"; 3 | import deleteDocument from "../controllers/documents/deleteDocument.js"; 4 | import getAllUserDocuments from "../controllers/documents/getAllUserDocuments.js"; 5 | import getSingleUserDocument from "../controllers/documents/getSingleUserDocument.js"; 6 | import updateDocument from "../controllers/documents/updateDocument.js"; 7 | import createDocumentPayment from "../controllers/documents/createPayment.js"; 8 | import { 9 | generatePDF, 10 | getPDF, 11 | sendDocument, 12 | } from "../controllers/documents/generatePDF.js"; 13 | 14 | import checkAuth from "../middleware/checkAuthMiddleware.js"; 15 | 16 | const router = express.Router(); 17 | 18 | // create a new document at /api/v1/document/create 19 | router.route("/create").post(checkAuth, createDocument); 20 | 21 | // get all of a users documents at /api/v1/document/all 22 | router.route("/all").get(checkAuth, getAllUserDocuments); 23 | 24 | // create document payment 25 | router.route("/:id/payment").post(checkAuth, createDocumentPayment); 26 | 27 | // get,update and delete document at /api/v1/document/:id 28 | router 29 | .route("/:id") 30 | .patch(checkAuth, updateDocument) 31 | .get(checkAuth, getSingleUserDocument) 32 | .delete(checkAuth, deleteDocument); 33 | 34 | // generate PDF document at /api/v1/document/generate-pdf 35 | router.route("/generate-pdf").post(generatePDF); 36 | // get pdf at /api/v1/document/get-pdf 37 | router.route("/get-pdf").get(getPDF); 38 | // send email with pdf at /api/v1/document/send-document 39 | router.route("/send-pdf").post(sendDocument); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /backend/routes/uploadRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cloudinaryUploader from "../config/cloudinaryConfig.js"; 3 | import upload from "../helpers/multer.js"; 4 | 5 | const router = express.Router(); 6 | 7 | router.route("/").patch(upload.single("logo"), async (req, res) => { 8 | const localFilePath = req.file.path; 9 | const result = await cloudinaryUploader(localFilePath); 10 | 11 | res.send(result.url); 12 | }); 13 | 14 | export default router; 15 | -------------------------------------------------------------------------------- /backend/routes/userRoutes.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import getUserProfile from "../controllers/user/getUserProfile.js"; 3 | import checkAuth from "../middleware/checkAuthMiddleware.js"; 4 | import updateUserProfile from "../controllers/user/updateUserProfile.js"; 5 | import deleteMyAccount from "../controllers/user/deleteMyAccount.js"; 6 | import getAllUserAccounts from "../controllers/user/getAllUserAccounts.js"; 7 | import deleteUserAccount from "../controllers/user/deleteUserAccount.js"; 8 | import deactivateUser from "../controllers/user/deactivateUser.js"; 9 | import role from "../middleware/roleMiddleware.js"; 10 | 11 | const router = express.Router(); 12 | 13 | router 14 | .route("/profile") 15 | .get(checkAuth, getUserProfile) 16 | .patch(checkAuth, updateUserProfile) 17 | .delete(checkAuth, deleteMyAccount); 18 | 19 | router 20 | .route("/all") 21 | .get(checkAuth, role.checkRole(role.ROLES.Admin), getAllUserAccounts); 22 | 23 | router 24 | .route("/:id") 25 | .delete(checkAuth, role.checkRole(role.ROLES.Admin), deleteUserAccount); 26 | 27 | router 28 | .route("/:id/deactivate") 29 | .patch(checkAuth, role.checkRole(role.ROLES.Admin), deactivateUser); 30 | export default router; 31 | -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import path from "path"; 3 | import cookieParser from "cookie-parser"; 4 | import "dotenv/config"; 5 | import express from "express"; 6 | import morgan from "morgan"; 7 | import connectionToDB from "./config/connectDB.js"; 8 | import { morganMiddleware, systemLogs } from "./utils/Logger.js"; 9 | import mongoSanitize from "express-mongo-sanitize"; 10 | import { errorHandler, notFound } from "./middleware/errorMiddleware.js"; 11 | import authRoutes from "./routes/authRoutes.js"; 12 | import userRoutes from "./routes/userRoutes.js"; 13 | import { apiLimiter } from "./middleware/apiLimiter.js"; 14 | import passport from "passport"; 15 | import googleAuth from "./config/passportSetup.js"; 16 | import customerRoutes from "./routes/customerRoutes.js"; 17 | import documentRoutes from "./routes/documentRoutes.js"; 18 | import uploadRoutes from "./routes/uploadRoutes.js"; 19 | 20 | await connectionToDB(); 21 | 22 | const app = express(); 23 | 24 | const __dirname = path.resolve(); 25 | app.use("/uploads", express.static(path.join(__dirname, "/uploads"))); 26 | app.use("/docs", express.static(path.join(__dirname, "/docs"))); 27 | 28 | if (process.env.NODE_ENV === "development") { 29 | app.use(morgan("dev")); 30 | } 31 | 32 | app.use(express.json()); 33 | 34 | app.use(express.urlencoded({ extended: false })); 35 | 36 | app.use(passport.initialize()); 37 | googleAuth(); 38 | 39 | app.use(cookieParser()); 40 | 41 | app.use(mongoSanitize()); 42 | 43 | app.use(morganMiddleware); 44 | 45 | app.get("/api/v1/test", (req, res) => { 46 | res.json({ Hi: "Welcome to the Invoice App" }); 47 | }); 48 | 49 | app.use("/api/v1/auth", authRoutes); 50 | app.use("/api/v1/user", apiLimiter, userRoutes); 51 | app.use("/api/v1/customer", apiLimiter, customerRoutes); 52 | app.use("/api/v1/document", apiLimiter, documentRoutes); 53 | app.use("/api/v1/upload", apiLimiter, uploadRoutes); 54 | 55 | // serve frontend 56 | if (process.env.NODE_ENV === "production") { 57 | app.use(express.static(path.join(__dirname, "client/build"))); 58 | 59 | app.get("*", (req, res) => 60 | res.sendFile(path.resolve(__dirname, "client", "build", "index.html")) 61 | ); 62 | } else { 63 | app.get("/", (req, res) => res.send("Please set to production")); 64 | } 65 | 66 | app.use(notFound); 67 | app.use(errorHandler); 68 | 69 | const PORT = process.env.PORT || 1997; 70 | 71 | app.listen(PORT, () => { 72 | console.log( 73 | `${chalk.green.bold("✔")} 👍 Server running in ${chalk.yellow.bold( 74 | process.env.NODE_ENV 75 | )} mode on port ${chalk.blue.bold(PORT)}` 76 | ); 77 | systemLogs.info( 78 | `Server running in ${process.env.NODE_ENV} mode on port ${PORT}` 79 | ); 80 | }); 81 | -------------------------------------------------------------------------------- /backend/utils/Logger.js: -------------------------------------------------------------------------------- 1 | import morgan from "morgan"; 2 | import { createLogger, format, transports } from "winston"; 3 | import "winston-daily-rotate-file"; 4 | 5 | const { combine, timestamp, prettyPrint } = format; 6 | 7 | const fileRotateTransport = new transports.DailyRotateFile({ 8 | filename: "logs/combined-%DATE%.log", 9 | datePattern: "YYYY-MM-DD", 10 | maxFiles: "14d", 11 | }); 12 | 13 | export const systemLogs = createLogger({ 14 | level: "http", 15 | format: combine( 16 | timestamp({ 17 | format: "YYYY-MM-DD hh:mm:ss.SSS A", 18 | }), 19 | prettyPrint() 20 | ), 21 | transports: [ 22 | fileRotateTransport, 23 | new transports.File({ 24 | level: "error", 25 | filename: "logs/error.log", 26 | }), 27 | ], 28 | exceptionHandlers: [ 29 | new transports.File({ filename: "logs/exception.log" }), 30 | ], 31 | rejectionHandlers: [ 32 | new transports.File({ filename: "logs/rejections.log" }), 33 | ], 34 | }); 35 | 36 | export const morganMiddleware = morgan( 37 | function (tokens, req, res) { 38 | return JSON.stringify({ 39 | method: tokens.method(req, res), 40 | url: tokens.url(req, res), 41 | status: Number.parseFloat(tokens.status(req, res)), 42 | content_length: tokens.res(req, res, "content-length"), 43 | response_time: Number.parseFloat(tokens["response-time"](req, res)), 44 | }); 45 | }, 46 | { 47 | stream: { 48 | write: (message) => { 49 | const data = JSON.parse(message); 50 | systemLogs.http(`incoming-request`, data); 51 | }, 52 | }, 53 | } 54 | ); 55 | -------------------------------------------------------------------------------- /backend/utils/emails/template/accountVerification.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | Verify Your Account 4 | 5 | 6 |

Verify Your Account

7 |

Hi {{name}}, thanks for registering an account with us.

8 |

Please verify your account by clicking the link 9 | verify your account 10 |

11 |

Kindly note that this link will be valid for the next 15 minutes only

12 | 13 | -------------------------------------------------------------------------------- /backend/utils/emails/template/requestResetPassword.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | Password Reset Request 4 | 5 | 6 |

Password Reset Request

7 |

Hi {{name}},

8 |

9 | It looks like you need a little help with your password. We're on it 10 |

11 |

If this was you, Please reset your password by clicking on 12 | Reset Password 13 |

14 |

If you didn't request for this, simply igore. Nothing will change in 15 | your account settings, an your account will remain secure

16 |

Be aware that this link is only valid for the next 15 minutes

17 | 18 | -------------------------------------------------------------------------------- /backend/utils/emails/template/resetPassword.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | Reset Password Success 4 | 5 | 6 |

Hi {{name}},

7 |

Your password has been changed successfully!

8 | 9 | -------------------------------------------------------------------------------- /backend/utils/emails/template/welcome.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | Welcome to MERN Invoice 4 | 5 | 6 |

Account Verified

7 |

Hi {{name}}, thanks for verifying your account.

8 |

You can now click this link to 9 | Login to your account 10 |

11 |

Hope you find our service of value!

12 | 13 | -------------------------------------------------------------------------------- /backend/utils/pdf/emailTemplate.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | export default function ({profile, document, totalAmountReceived}) { 4 | return ` 5 | 6 | 7 | 8 | 54 | Email Template 55 | 56 | 57 | 58 |
59 |
60 | 61 |

${ 62 | profile?.businessName 63 | ? profile?.businessName 64 | : profile.firstName 65 | }

66 | 67 |
68 |

Dear Esteemed customer, ${ 69 | document.customer.name 70 | }

71 |

I trust this email find you well. Kindly find attached as a pdf your, ${ 72 | document.documentType 73 | }

74 |

If you have paid, please ignore this message... Your current balance is ${ 75 | document?.currency 76 | } 77 | ${Math.round(document?.total - totalAmountReceived).toFixed( 78 | 2 79 | )} , due on ${moment(document?.dueDate).format("DD-MM-YYYY")} 80 |

81 | 82 | 87 |
88 | 89 | 90 | 91 | 92 | 93 | `; 94 | } 95 | -------------------------------------------------------------------------------- /backend/utils/pdf/options.js: -------------------------------------------------------------------------------- 1 | export default { 2 | format: "A4", 3 | orientation: "portrait", 4 | border: "10mm", 5 | width: "950px", 6 | }; 7 | -------------------------------------------------------------------------------- /backend/utils/sendEmail.js: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import fs from "fs"; 3 | import handlebars from "handlebars"; 4 | import path from "path"; 5 | import { fileURLToPath } from "url"; 6 | import transporter from "../helpers/emailTransport.js"; 7 | import { systemLogs } from "./Logger.js"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | const sendEmail = async (email, subject, payload, template) => { 13 | try { 14 | const sourceDirectory = fs.readFileSync( 15 | path.join(__dirname, template), 16 | "utf8" 17 | ); 18 | 19 | const compiledTemplate = handlebars.compile(sourceDirectory); 20 | 21 | const emailOptions = { 22 | from: process.env.SENDER_EMAIL, 23 | to: email, 24 | subject: subject, 25 | html: compiledTemplate(payload), 26 | }; 27 | await transporter.sendMail(emailOptions); 28 | } catch (error) { 29 | systemLogs.error(`email not sent: ${error}`); 30 | } 31 | }; 32 | 33 | export default sendEmail; 34 | -------------------------------------------------------------------------------- /client/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/.DS_Store -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App and Redux 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) template. 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /client/build/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "main.css": "/static/css/main.425bd0e3.css", 4 | "main.js": "/static/js/main.e67fa813.js", 5 | "static/js/787.c4e7f8f9.chunk.js": "/static/js/787.c4e7f8f9.chunk.js", 6 | "static/media/buildings.jpg": "/static/media/buildings.ec0090336eaa34291eb9.jpg", 7 | "static/media/add_customer.svg": "/static/media/add_customer.8a9aa94a02fc33d6ea5031ca81f510bc.svg", 8 | "static/media/add_bill.svg": "/static/media/add_bill.f6fc81c86179dd1c7ca4bcff6d087367.svg", 9 | "index.html": "/index.html", 10 | "main.425bd0e3.css.map": "/static/css/main.425bd0e3.css.map", 11 | "main.e67fa813.js.map": "/static/js/main.e67fa813.js.map", 12 | "787.c4e7f8f9.chunk.js.map": "/static/js/787.c4e7f8f9.chunk.js.map" 13 | }, 14 | "entrypoints": [ 15 | "static/css/main.425bd0e3.css", 16 | "static/js/main.e67fa813.js" 17 | ] 18 | } -------------------------------------------------------------------------------- /client/build/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/build/favicon.ico -------------------------------------------------------------------------------- /client/build/index.html: -------------------------------------------------------------------------------- 1 | MERN Invoice
-------------------------------------------------------------------------------- /client/build/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/build/logo192.png -------------------------------------------------------------------------------- /client/build/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/build/logo512.png -------------------------------------------------------------------------------- /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/build/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /client/build/static/js/787.c4e7f8f9.chunk.js: -------------------------------------------------------------------------------- 1 | "use strict";(self.webpackChunkclient=self.webpackChunkclient||[]).push([[787],{787:function(e,t,n){n.r(t),n.d(t,{getCLS:function(){return y},getFCP:function(){return g},getFID:function(){return C},getLCP:function(){return P},getTTFB:function(){return D}});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver((function(e){return e.getEntries().map(t)}));return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",(function(t){t.persisted&&e(t)}),!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,p=function(){return"hidden"===document.visibilityState?0:1/0},d=function(){f((function(e){var t=e.timeStamp;v=t}),!0)},l=function(){return v<0&&(v=p(),d(),s((function(){setTimeout((function(){v=p(),d()}),0)}))),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},p=c("layout-shift",v);p&&(n=m(i,r,t),f((function(){p.takeRecords().map(v),n(!0)})),s((function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)})))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(t){return e(t,b,E)}))},C=function(e,t){var n,a=l(),v=u("FID"),p=function(e){e.startTimeperformance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",(function(){return setTimeout(t,0)}))}}}]); 2 | //# sourceMappingURL=787.c4e7f8f9.chunk.js.map -------------------------------------------------------------------------------- /client/build/static/js/main.e67fa813.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ 2 | 3 | /** 4 | * @license React 5 | * react-dom.production.min.js 6 | * 7 | * Copyright (c) Facebook, Inc. and its affiliates. 8 | * 9 | * This source code is licensed under the MIT license found in the 10 | * LICENSE file in the root directory of this source tree. 11 | */ 12 | 13 | /** 14 | * @license React 15 | * react-is.production.min.js 16 | * 17 | * Copyright (c) Facebook, Inc. and its affiliates. 18 | * 19 | * This source code is licensed under the MIT license found in the 20 | * LICENSE file in the root directory of this source tree. 21 | */ 22 | 23 | /** 24 | * @license React 25 | * react-jsx-runtime.production.min.js 26 | * 27 | * Copyright (c) Facebook, Inc. and its affiliates. 28 | * 29 | * This source code is licensed under the MIT license found in the 30 | * LICENSE file in the root directory of this source tree. 31 | */ 32 | 33 | /** 34 | * @license React 35 | * react.production.min.js 36 | * 37 | * Copyright (c) Facebook, Inc. and its affiliates. 38 | * 39 | * This source code is licensed under the MIT license found in the 40 | * LICENSE file in the root directory of this source tree. 41 | */ 42 | 43 | /** 44 | * @license React 45 | * scheduler.production.min.js 46 | * 47 | * Copyright (c) Facebook, Inc. and its affiliates. 48 | * 49 | * This source code is licensed under the MIT license found in the 50 | * LICENSE file in the root directory of this source tree. 51 | */ 52 | 53 | /** 54 | * @license React 55 | * use-sync-external-store-shim.production.min.js 56 | * 57 | * Copyright (c) Facebook, Inc. and its affiliates. 58 | * 59 | * This source code is licensed under the MIT license found in the 60 | * LICENSE file in the root directory of this source tree. 61 | */ 62 | 63 | /** 64 | * @license React 65 | * use-sync-external-store-shim/with-selector.production.min.js 66 | * 67 | * Copyright (c) Facebook, Inc. and its affiliates. 68 | * 69 | * This source code is licensed under the MIT license found in the 70 | * LICENSE file in the root directory of this source tree. 71 | */ 72 | 73 | /** 74 | * @remix-run/router v1.0.3 75 | * 76 | * Copyright (c) Remix Software Inc. 77 | * 78 | * This source code is licensed under the MIT license found in the 79 | * LICENSE.md file in the root directory of this source tree. 80 | * 81 | * @license MIT 82 | */ 83 | 84 | /** 85 | * React Router DOM v6.4.3 86 | * 87 | * Copyright (c) Remix Software Inc. 88 | * 89 | * This source code is licensed under the MIT license found in the 90 | * LICENSE.md file in the root directory of this source tree. 91 | * 92 | * @license MIT 93 | */ 94 | 95 | /** 96 | * React Router v6.4.3 97 | * 98 | * Copyright (c) Remix Software Inc. 99 | * 100 | * This source code is licensed under the MIT license found in the 101 | * LICENSE.md file in the root directory of this source tree. 102 | * 103 | * @license MIT 104 | */ 105 | 106 | /** @license MUI v5.10.16 107 | * 108 | * This source code is licensed under the MIT license found in the 109 | * LICENSE file in the root directory of this source tree. 110 | */ 111 | 112 | /** @license React v16.13.1 113 | * react-is.production.min.js 114 | * 115 | * Copyright (c) Facebook, Inc. and its affiliates. 116 | * 117 | * This source code is licensed under the MIT license found in the 118 | * LICENSE file in the root directory of this source tree. 119 | */ 120 | 121 | //! moment.js 122 | -------------------------------------------------------------------------------- /client/build/static/media/buildings.ec0090336eaa34291eb9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/build/static/media/buildings.ec0090336eaa34291eb9.jpg -------------------------------------------------------------------------------- /client/docker/local/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=16-alpine3.12 2 | 3 | FROM node:${NODE_VERSION} 4 | 5 | ARG APP_HOME=/app 6 | 7 | WORKDIR ${APP_HOME} 8 | 9 | COPY package*.json ./ 10 | 11 | RUN npm install 12 | 13 | COPY . ${APP_HOME} 14 | 15 | CMD ["npm","start"] -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.10.5", 7 | "@emotion/styled": "^11.10.5", 8 | "@fvilers/disable-react-devtools": "^1.3.0", 9 | "@mui/icons-material": "^5.10.16", 10 | "@mui/material": "^5.10.16", 11 | "@mui/x-date-pickers": "^5.0.9", 12 | "@reduxjs/toolkit": "^1.8.6", 13 | "@testing-library/jest-dom": "^5.16.5", 14 | "@testing-library/react": "^13.4.0", 15 | "@testing-library/user-event": "^14.4.3", 16 | "axios": "^1.3.0", 17 | "date-fns": "^2.29.3", 18 | "file-saver": "^2.0.5", 19 | "formik": "^2.2.9", 20 | "framer-motion": "^7.6.15", 21 | "immer": "^9.0.19", 22 | "moment": "^2.29.4", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-icons": "^4.7.1", 26 | "react-jwt": "^1.1.7", 27 | "react-redux": "^8.0.4", 28 | "react-router-dom": "^6.4.3", 29 | "react-scripts": "5.0.1", 30 | "react-toastify": "^9.1.1", 31 | "validator": "^13.7.0", 32 | "web-vitals": "^2.1.4", 33 | "yup": "^0.32.11" 34 | }, 35 | "scripts": { 36 | "start": "react-scripts start", 37 | "build": "react-scripts build", 38 | "test": "react-scripts test", 39 | "eject": "react-scripts eject" 40 | }, 41 | "eslintConfig": { 42 | "extends": [ 43 | "react-app", 44 | "react-app/jest" 45 | ] 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | MERN Invoice 16 | 17 | 18 | 19 | 20 |
21 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/public/logo512.png -------------------------------------------------------------------------------- /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/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline } from "@mui/material"; 2 | import { ThemeProvider } from "@mui/material/styles"; 3 | import { Route, Routes } from "react-router-dom"; 4 | import { ToastContainer } from "react-toastify"; 5 | import "react-toastify/dist/ReactToastify.min.css"; 6 | import Footer from "./components/Footer"; 7 | import Layout from "./components/Layout"; 8 | import NotFound from "./components/NotFound"; 9 | import { customTheme } from "./customTheme"; 10 | import useTitle from "./hooks/useTitle"; 11 | import { useSelector } from "react-redux"; 12 | import Navbar from "./components/Navbar"; 13 | import HomePage from "./pages/HomePage"; 14 | import RegisterPage from "./features/auth/pages/RegisterPage"; 15 | import VerifiedPage from "./features/auth/pages/VerifiedPage"; 16 | import LoginPage from "./features/auth/pages/LoginPage"; 17 | import ResendEmailTokenPage from "./features/auth/pages/ResendEmailTokenPage"; 18 | import PasswordResetRequestPage from "./features/auth/pages/PasswordResetRequestPage"; 19 | import PasswordResetPage from "./features/auth/pages/PasswordResetPage"; 20 | import { ROLES } from "./config/roles"; 21 | import UsersList from "./features/users/pages/UsersListPage"; 22 | import DashboardPage from "./features/dashboard/pages/DashboardPage"; 23 | import AuthRequired from "./components/AuthRequired"; 24 | import EditProfileForm from "./features/users/pages/EditProfileForm"; 25 | import ProfilePage from "./features/users/pages/ProfilePage"; 26 | import CustomerCreateForm from "./features/customers/pages/CustomerCreateForm"; 27 | import CustomerEditForm from "./features/customers/pages/CustomerEditForm"; 28 | import CustomersPage from "./features/customers/pages/CustomersPage"; 29 | import SingleCustomerPage from "./features/customers/pages/SingleCustomerPage"; 30 | import DocCreateEditForm from "./features/documents/pages/DocCreateEditForm"; 31 | import DocumentsPage from "./features/documents/pages/DocumentsPage"; 32 | 33 | import SingleDocumentPage from "./features/documents/pages/SingleDocumentPage"; 34 | 35 | const App = () => { 36 | useTitle("MERN Invoice - Home"); 37 | const { user } = useSelector((state) => state.auth); 38 | return ( 39 | 40 | 41 | {user && } 42 | 43 | }> 44 | } /> 45 | } /> 46 | } /> 47 | } /> 48 | } /> 49 | } 52 | /> 53 | } 56 | /> 57 | {/* Private Routes - Users */} 58 | } 60 | > 61 | } /> 62 | } 65 | /> 66 | } /> 67 | } 70 | /> 71 | } 74 | /> 75 | } 78 | /> 79 | 80 | } /> 81 | } 84 | /> 85 | } 88 | /> 89 | } 92 | /> 93 | } /> 94 | 95 | 96 | {/* Private Routes - Admin Users only */} 97 | } 99 | > 100 | } /> 101 | 102 | 103 | } /> 104 | 105 | 106 |