├── .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 |
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 You need to enable JavaScript to run this app.
--------------------------------------------------------------------------------
/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 | You need to enable JavaScript to run this app.
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 |
107 |
108 |
109 | );
110 | };
111 |
112 | export default App;
113 |
--------------------------------------------------------------------------------
/client/src/animations/authButtonAnimations.js:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 |
3 | export default function AuthButtonAnimation({ children, type }) {
4 | switch (type) {
5 | default:
6 | return (
7 |
11 | {children}
12 |
13 | );
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/app/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import { baseApiSlice } from "../features/api/baseApiSlice";
3 | import authReducer from "../features/auth/authSlice";
4 |
5 | export const store = configureStore({
6 | reducer: {
7 | [baseApiSlice.reducerPath]: baseApiSlice.reducer,
8 | auth: authReducer,
9 | },
10 | middleware: (getDefaultMiddleware) =>
11 | getDefaultMiddleware().concat(baseApiSlice.middleware),
12 | // TODO: change this to false in production
13 | devTools: false,
14 | });
15 |
--------------------------------------------------------------------------------
/client/src/components/AuthRequired.jsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Outlet, useLocation } from "react-router-dom";
2 | import useAuthUser from "../hooks/useAuthUser";
3 |
4 | const AuthRequired = ({ allowedRoles }) => {
5 | const location = useLocation();
6 |
7 | const { roles } = useAuthUser();
8 |
9 | return roles.some((role) => allowedRoles.includes(role)) ? (
10 |
11 | ) : (
12 |
13 | );
14 | };
15 |
16 | export default AuthRequired;
17 |
--------------------------------------------------------------------------------
/client/src/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import { Box, CssBaseline, Link, Typography } from "@mui/material";
2 |
3 | import { FaMoneyBillWave } from "react-icons/fa";
4 |
5 | function Copyright() {
6 | return (
7 |
8 | {"Copyright ©"}
9 |
10 | MERN Invoice
11 | {" "}
12 | {new Date().getFullYear()} {"."}
13 |
14 | );
15 | }
16 |
17 | const Footer = () => {
18 | return (
19 |
26 |
27 |
28 |
37 |
43 | Because Money is as important as oxygen!{" "}
44 |
45 |
46 |
47 |
48 |
49 | );
50 | };
51 |
52 | export default Footer;
53 |
--------------------------------------------------------------------------------
/client/src/components/GoogleLogin.jsx:
--------------------------------------------------------------------------------
1 | import Box from "@mui/material/Box";
2 | import { FcGoogle } from "react-icons/fc";
3 |
4 | const GoogleLogin = () => {
5 | const google = () => {
6 | // TODO: change this in production
7 | window.open("http://www.apiimperfect.site/api/v1/auth/google", "_self");
8 | };
9 | return (
10 |
11 |
12 |
13 | );
14 | };
15 |
16 | export default GoogleLogin;
17 |
--------------------------------------------------------------------------------
/client/src/components/Layout.jsx:
--------------------------------------------------------------------------------
1 | import { Box } from "@mui/material";
2 | import { Outlet } from "react-router-dom";
3 |
4 | const Layout = () => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
14 | export default Layout;
15 |
--------------------------------------------------------------------------------
/client/src/components/Navbar/AuthNav.jsx:
--------------------------------------------------------------------------------
1 | import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
2 | import ChevronRightIcon from "@mui/icons-material/ChevronRight";
3 | import MenuIcon from "@mui/icons-material/Menu";
4 | import { Box, CssBaseline, Divider, Stack, Toolbar } from "@mui/material";
5 | import MuiAppBar from "@mui/material/AppBar";
6 | import MuiDrawer from "@mui/material/Drawer";
7 | import IconButton from "@mui/material/IconButton";
8 | import { styled, useTheme } from "@mui/material/styles";
9 | import { useState, useEffect } from "react";
10 | import { useSelector, useDispatch } from "react-redux";
11 | import { isExpired } from "react-jwt";
12 | import { logOut } from "../../features/auth/authSlice";
13 | import { toast } from "react-toastify";
14 | import { useNavigate } from "react-router-dom";
15 | import Logo from "./Logo";
16 | import MenuList from "./MenuList";
17 | import ProfileInfo from "./ProfileInfo";
18 |
19 | const drawerWidth = 240;
20 |
21 | const openedMixin = (theme) => ({
22 | width: drawerWidth,
23 | transition: theme.transitions.create("width", {
24 | easing: theme.transitions.easing.sharp,
25 | duration: theme.transitions.duration.enteringScreen,
26 | }),
27 | overflowX: "hidden",
28 | });
29 |
30 | const closedMixin = (theme) => ({
31 | transition: theme.transitions.create("width", {
32 | easing: theme.transitions.easing.sharp,
33 | duration: theme.transitions.duration.leavingScreen,
34 | }),
35 | overflowX: "hidden",
36 | width: `calc(${theme.spacing(7)} + 1px)`,
37 | [theme.breakpoints.up("sm")]: {
38 | width: `calc(${theme.spacing(8)} + 1px)`,
39 | },
40 | });
41 |
42 | const DrawerHeader = styled("div")(({ theme }) => ({
43 | display: "flex",
44 | alignItems: "center",
45 | justifyContent: "flex-end",
46 | padding: theme.spacing(0, 1),
47 | // necessary for content to be below app bar
48 | ...theme.mixins.toolbar,
49 | }));
50 |
51 | const AppBar = styled(MuiAppBar, {
52 | shouldForwardProp: (prop) => prop !== "open",
53 | })(({ theme, open }) => ({
54 | zIndex: theme.zIndex.drawer + 1,
55 | transition: theme.transitions.create(["width", "margin"], {
56 | easing: theme.transitions.easing.sharp,
57 | duration: theme.transitions.duration.leavingScreen,
58 | }),
59 | ...(open && {
60 | marginLeft: drawerWidth,
61 | width: `calc(100% - ${drawerWidth}px)`,
62 | transition: theme.transitions.create(["width", "margin"], {
63 | easing: theme.transitions.easing.sharp,
64 | duration: theme.transitions.duration.enteringScreen,
65 | }),
66 | }),
67 | }));
68 |
69 | const Drawer = styled(MuiDrawer, {
70 | shouldForwardProp: (prop) => prop !== "open",
71 | })(({ theme, open }) => ({
72 | width: drawerWidth,
73 | flexShrink: 0,
74 | whiteSpace: "nowrap",
75 | boxSizing: "border-box",
76 | ...(open && {
77 | ...openedMixin(theme),
78 | "& .MuiDrawer-paper": openedMixin(theme),
79 | }),
80 | ...(!open && {
81 | ...closedMixin(theme),
82 | "& .MuiDrawer-paper": closedMixin(theme),
83 | }),
84 | }));
85 |
86 | const AuthNav = () => {
87 | const { user, googleToken } = useSelector((state) => state.auth);
88 | const navigate = useNavigate();
89 | const dispatch = useDispatch();
90 | const theme = useTheme();
91 | const [open, setOpen] = useState(false);
92 |
93 | const handleDrawerOpen = () => {
94 | setOpen(true);
95 | };
96 |
97 | const handleDrawerClose = () => {
98 | setOpen(false);
99 | };
100 |
101 | useEffect(() => {
102 | if (googleToken) {
103 | const isMyTokenExpired = isExpired(googleToken);
104 |
105 | if (isMyTokenExpired) {
106 | dispatch(logOut());
107 | navigate("/login");
108 | toast.warning("Your session has expired, login again");
109 | }
110 | }
111 | }, [navigate, dispatch, googleToken]);
112 |
113 | return (
114 |
115 |
116 |
117 |
122 |
123 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | {theme.direction === "rtl" ? (
146 |
150 | ) : (
151 |
152 | )}
153 |
154 |
155 |
156 |
157 |
158 |
159 | );
160 | };
161 |
162 | export default AuthNav;
163 |
--------------------------------------------------------------------------------
/client/src/components/Navbar/Logo.jsx:
--------------------------------------------------------------------------------
1 | import CurrencyExchangeIcon from "@mui/icons-material/CurrencyExchange";
2 | import { Link, Stack, Typography } from "@mui/material";
3 | import { Link as RouterLink } from "react-router-dom";
4 |
5 | const Logo = () => {
6 | return (
7 |
8 |
9 |
18 |
19 |
28 |
33 | MERN INVOICE
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export default Logo;
41 |
--------------------------------------------------------------------------------
/client/src/components/Navbar/MenuList.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Divider,
4 | List,
5 | ListItem,
6 | ListItemButton,
7 | ListItemIcon,
8 | ListItemText,
9 | styled,
10 | } from "@mui/material";
11 |
12 | import { useNavigate } from "react-router-dom";
13 | import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
14 | import BarChartIcon from "@mui/icons-material/BarChart";
15 | import ManageAccountsIcon from "@mui/icons-material/ManageAccounts";
16 | import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
17 | import PointOfSaleIcon from "@mui/icons-material/PointOfSale";
18 | import useAuthUser from "../../hooks/useAuthUser";
19 |
20 | const StyledList = styled(List)({
21 | "&:hover": {
22 | backgroundColor: "#555a64",
23 | },
24 | });
25 |
26 | const StyledSideMenuDivider = styled(Divider)({
27 | height: "2px",
28 | borderColor: "#ffffff63",
29 | });
30 |
31 | const MenuList = () => {
32 | const navigate = useNavigate();
33 |
34 | const { isAdmin } = useAuthUser();
35 |
36 | return (
37 |
38 |
39 |
40 | navigate("/profile")}>
41 |
42 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | navigate("/dashboard")}>
56 |
57 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | navigate("/documents")}>
71 |
72 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | navigate("/customers")}>
86 |
87 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | {isAdmin && (
99 |
100 |
101 | navigate("/users")}>
102 |
103 |
107 |
108 |
109 |
110 |
111 |
112 | )}
113 |
114 | );
115 | };
116 |
117 | export default MenuList;
118 |
--------------------------------------------------------------------------------
/client/src/components/Navbar/index.jsx:
--------------------------------------------------------------------------------
1 | import AuthNav from "./AuthNav";
2 |
3 | const Navbar = () => {
4 | return ;
5 | };
6 |
7 | export default Navbar;
8 |
--------------------------------------------------------------------------------
/client/src/components/NormalDivider.jsx:
--------------------------------------------------------------------------------
1 | import { Divider, styled } from "@mui/material";
2 |
3 | const DividerStyle = styled(Divider)({
4 | height: "3px",
5 | backgroundColor: "rgb(17,65,141)",
6 | marginBottom: "20px",
7 | marginTop: "25px",
8 | });
9 |
10 | const NormalDivider = () => {
11 | return ;
12 | };
13 |
14 | export default NormalDivider;
15 |
--------------------------------------------------------------------------------
/client/src/components/NotFound.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Container, Typography } from "@mui/material";
2 | import { FaHeartBroken, FaSadTear } from "react-icons/fa";
3 |
4 | const NotFound = () => {
5 | return (
6 |
7 |
16 |
21 | 404 Not Found
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default NotFound;
33 |
--------------------------------------------------------------------------------
/client/src/components/Spinner.jsx:
--------------------------------------------------------------------------------
1 | import Box from "@mui/material/Box";
2 | import "../styles/spinner.css";
3 |
4 | const Spinner = () => {
5 | return (
6 |
7 |
8 |
9 | );
10 | };
11 |
12 | export default Spinner;
13 |
--------------------------------------------------------------------------------
/client/src/components/StyledContainer.jsx:
--------------------------------------------------------------------------------
1 | import { Box, styled } from "@mui/material";
2 |
3 | const StyledBox = styled(Box)({
4 | width: "100%",
5 | margin: "20px auto 100px auto",
6 | borderRadius: "10px",
7 | padding: "20px",
8 | boxShadow: "0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.6)",
9 | });
10 |
11 | const StyledContainer = ({ children }) => {
12 | return {children} ;
13 | };
14 |
15 | export default StyledContainer;
16 |
--------------------------------------------------------------------------------
/client/src/components/StyledDashboardGrid.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Grid, styled } from "@mui/material";
2 |
3 | const StyledBox = styled(Box)({
4 | width: "100%",
5 | marginTop: "20px",
6 | marginLeft: "auto",
7 | marginRight: "auto",
8 | borderRadius: "10px",
9 | padding: "20px",
10 | border: "1px dashed #5a5a5a",
11 | borderWidth: "2px",
12 | });
13 |
14 | const StyledDashboardGrid = ({ children }) => {
15 | return (
16 |
17 | {children}
18 |
19 | );
20 | };
21 |
22 | export default StyledDashboardGrid;
23 |
--------------------------------------------------------------------------------
/client/src/components/StyledDivider.jsx:
--------------------------------------------------------------------------------
1 | import { Divider, styled } from "@mui/material";
2 |
3 | const DividerStyle = styled(Divider)({
4 | width: "50%",
5 | marginTop: "10px",
6 | marginBottom: "15px",
7 | marginLeft: "auto",
8 | marginRight: "auto",
9 | height: "3px",
10 | backgroundImage:
11 | "linear-gradient(to right,rgba(0,0,0,0), rgba(9,84,132), rgba(0,0,0,0))",
12 | });
13 |
14 | const StyledDivider = () => {
15 | return ;
16 | };
17 |
18 | export default StyledDivider;
19 |
--------------------------------------------------------------------------------
/client/src/components/StyledTableCell.jsx:
--------------------------------------------------------------------------------
1 | import { styled, TableCell, tableCellClasses } from "@mui/material";
2 |
3 | const TableCellStyled = styled(TableCell)(({ theme }) => ({
4 | [`&.${tableCellClasses.head}`]: {
5 | backgroundColor: theme.palette.common.black,
6 | color: theme.palette.common.white,
7 | },
8 | [`&.${tableCellClasses.body}`]: {
9 | fontSize: 15,
10 | },
11 | }));
12 |
13 | const StyledTableCell = ({ children }) => {
14 | return {children} ;
15 | };
16 |
17 | export default StyledTableCell;
18 |
--------------------------------------------------------------------------------
/client/src/components/StyledTableRow.jsx:
--------------------------------------------------------------------------------
1 | import { styled, TableRow } from "@mui/material";
2 |
3 | const TableRowStyled = styled(TableRow)(({ theme }) => ({
4 | "&:nth-of-type(odd)": {
5 | backgroundColor: theme.palette.action.hover,
6 | },
7 | "&:last-child td, &:last-child th": {
8 | border: 0,
9 | },
10 | }));
11 |
12 | const StyledTableRow = ({ children }) => {
13 | return {children} ;
14 | };
15 |
16 | export default StyledTableRow;
17 |
--------------------------------------------------------------------------------
/client/src/components/TablePaginationActions.jsx:
--------------------------------------------------------------------------------
1 | import FirstPageIcon from "@mui/icons-material/FirstPage";
2 | import KeyboardArrowLeft from "@mui/icons-material/KeyboardArrowLeft";
3 | import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
4 | import LastPageIcon from "@mui/icons-material/LastPage";
5 | import { Box } from "@mui/material";
6 | import IconButton from "@mui/material/IconButton";
7 | import { useTheme } from "@mui/material/styles";
8 |
9 | export default function TablePaginationActions(props) {
10 | const theme = useTheme();
11 | const { count, page, rowsPerPage, onPageChange } = props;
12 |
13 | const handleFirstPageButtonClick = (event) => {
14 | onPageChange(event, 0);
15 | };
16 |
17 | const handleBackButtonClick = (event) => {
18 | onPageChange(event, page - 1);
19 | };
20 |
21 | const handleNextButtonClick = (event) => {
22 | onPageChange(event, page + 1);
23 | };
24 |
25 | const handleLastPageButtonClick = (event) => {
26 | onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
27 | };
28 |
29 | return (
30 |
31 |
36 | {theme.direction === "rtl" ? (
37 |
38 | ) : (
39 |
40 | )}
41 |
42 |
47 | {theme.direction === "rtl" ? (
48 |
49 | ) : (
50 |
51 | )}
52 |
53 | = Math.ceil(count / rowsPerPage) - 1}
56 | aria-label="next page"
57 | >
58 | {theme.direction === "rtl" ? (
59 |
60 | ) : (
61 |
62 | )}
63 |
64 | = Math.ceil(count / rowsPerPage) - 1}
67 | aria-label="last page"
68 | >
69 | {theme.direction === "rtl" ? (
70 |
71 | ) : (
72 |
73 | )}
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/client/src/config/roles.js:
--------------------------------------------------------------------------------
1 | export const ROLES = {
2 | User: "User",
3 | Admin: "Admin",
4 | };
5 |
--------------------------------------------------------------------------------
/client/src/customTheme.js:
--------------------------------------------------------------------------------
1 | import { createTheme } from "@mui/material";
2 |
3 | export const customTheme = createTheme({
4 | palette: {
5 | background: {
6 | default: "#f8f9fa",
7 | },
8 | indigo: {
9 | main: "#6610f2",
10 | },
11 | orange: {
12 | main: "#f4511e",
13 | },
14 | green: {
15 | main: "#07f011",
16 | },
17 | blue: {
18 | main: "#34aadc",
19 | },
20 | yellow: {
21 | main: "#f57c00",
22 | },
23 | darkRed: {
24 | main: "#7f0000",
25 | },
26 | },
27 | components: {
28 | MuiDrawer: {
29 | styleOverrides: {
30 | // Name of the slot
31 | paper: {
32 | // Some CSS
33 | backgroundColor: "#000000",
34 | color: "#fff",
35 | },
36 | },
37 | },
38 | MuiAppBar: {
39 | styleOverrides: {
40 | root: {
41 | backgroundColor: "#000000",
42 | color: "#fff",
43 | },
44 | },
45 | },
46 | MuiListItemText: {
47 | styleOverrides: {
48 | primary: {
49 | fontSize: "1.3rem",
50 | },
51 | },
52 | },
53 | },
54 | });
55 |
--------------------------------------------------------------------------------
/client/src/features/api/baseApiSlice.js:
--------------------------------------------------------------------------------
1 | import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
2 | import { logIn, logOut } from "../auth/authSlice";
3 |
4 | const baseQuery = fetchBaseQuery({
5 | baseUrl: "/api/v1",
6 | credentials: "include",
7 | prepareHeaders: (headers, { getState }) => {
8 | const token = getState().auth.user?.accessToken;
9 | const googleToken = getState().auth.googleToken;
10 | if (token) {
11 | headers.set("authorization", `Bearer ${token}`);
12 | } else if (googleToken) {
13 | headers.set("authorization", `Bearer ${googleToken}`);
14 | }
15 | return headers;
16 | },
17 | });
18 |
19 | const baseQueryWithRefreshToken = async (args, api, extraOptions) => {
20 | let response = await baseQuery(args, api, extraOptions);
21 |
22 | if (response?.error?.originalStatus === 403) {
23 | const refreshResponse = await baseQuery(
24 | "/auth/new_access_token",
25 | api,
26 | extraOptions
27 | );
28 |
29 | if (refreshResponse?.data) {
30 | api.dispatch(logIn({ ...refreshResponse.data }));
31 | response = await baseQuery(args, api, extraOptions);
32 | } else {
33 | api.dispatch(logOut());
34 | }
35 | }
36 | return response;
37 | };
38 |
39 | export const baseApiSlice = createApi({
40 | reducerPath: "api",
41 | baseQuery: baseQueryWithRefreshToken,
42 | tagTypes: ["User", "Customer", "Document"],
43 | endpoints: (builder) => ({}),
44 | });
45 |
--------------------------------------------------------------------------------
/client/src/features/auth/authApiSlice.js:
--------------------------------------------------------------------------------
1 | import { baseApiSlice } from "../api/baseApiSlice";
2 | import { logOut } from "./authSlice";
3 |
4 | export const authApiSlice = baseApiSlice.injectEndpoints({
5 | endpoints: (builder) => ({
6 | registerUser: builder.mutation({
7 | query: (userData) => ({
8 | url: "/auth/register",
9 | method: "POST",
10 | body: userData,
11 | }),
12 | }),
13 | loginUser: builder.mutation({
14 | query: (credentials) => ({
15 | url: "/auth/login",
16 | method: "POST",
17 | body: credentials,
18 | }),
19 | }),
20 | logoutUser: builder.mutation({
21 | query: () => ({
22 | url: "/auth/logout",
23 | method: "GET",
24 | }),
25 |
26 | async onQueryStarted(arg, { dispatch, queryFulfilled }) {
27 | try {
28 | dispatch(logOut());
29 | dispatch(baseApiSlice.util.resetApiState());
30 | } catch (err) {
31 | console.log(err);
32 | }
33 | },
34 | }),
35 | resendVerifyEmail: builder.mutation({
36 | query: (userEmail) => ({
37 | url: "/auth/resend_email_token",
38 | method: "POST",
39 | body: userEmail,
40 | }),
41 | }),
42 | passwordResetRequest: builder.mutation({
43 | query: (formData) => ({
44 | url: "/auth/reset_password_request",
45 | method: "POST",
46 | body: formData,
47 | }),
48 | }),
49 | resetPassword: builder.mutation({
50 | query: (formData) => ({
51 | url: "/auth/reset_password",
52 | method: "POST",
53 | body: formData,
54 | }),
55 | }),
56 | }),
57 | });
58 |
59 | export const {
60 | useRegisterUserMutation,
61 | useLoginUserMutation,
62 | useLogoutUserMutation,
63 | useResendVerifyEmailMutation,
64 | usePasswordResetRequestMutation,
65 | useResetPasswordMutation,
66 | } = authApiSlice;
67 |
--------------------------------------------------------------------------------
/client/src/features/auth/authSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import { decodeToken } from "react-jwt";
3 |
4 | const user = JSON.parse(localStorage.getItem("user"));
5 | const googleToken = localStorage.getItem("googleToken");
6 |
7 | const decodedToken = decodeToken(googleToken);
8 |
9 | const initialState = {
10 | user: user ? user : decodedToken,
11 | googleToken: googleToken ? googleToken : null,
12 | };
13 |
14 | const authSlice = createSlice({
15 | name: "auth",
16 | initialState,
17 | reducers: {
18 | logIn: (state, action) => {
19 | state.user = action.payload;
20 | localStorage.setItem("user", JSON.stringify(action.payload));
21 | },
22 | logOut: (state, action) => {
23 | state.user = null;
24 | state.googleToken = null;
25 | localStorage.removeItem("user");
26 | localStorage.removeItem("googleToken");
27 | },
28 | },
29 | });
30 |
31 | export const { logIn, logOut } = authSlice.actions;
32 |
33 | export default authSlice.reducer;
34 |
35 | export const selectCurrentUserToken = (state) => state.auth.user?.accessToken;
36 |
37 | export const selectCurrentUserGoogleToken = (state) => state.auth?.googleToken;
38 |
--------------------------------------------------------------------------------
/client/src/features/auth/forms/AuthWrapper.jsx:
--------------------------------------------------------------------------------
1 | import { Box } from "@mui/material";
2 |
3 | const AuthWrapper = ({ children }) => {
4 | return (
5 |
13 | {children}
14 |
15 | );
16 | };
17 |
18 | export default AuthWrapper;
19 |
--------------------------------------------------------------------------------
/client/src/features/auth/forms/LoginForm.jsx:
--------------------------------------------------------------------------------
1 | import Visibility from "@mui/icons-material/Visibility";
2 | import VisibilityOff from "@mui/icons-material/VisibilityOff";
3 | import { useEffect, useState } from "react";
4 | import { useDispatch } from "react-redux";
5 | import { Link as RouterLink, useNavigate, useLocation } from "react-router-dom";
6 | import { toast } from "react-toastify";
7 | import * as Yup from "yup";
8 | import {
9 | Box,
10 | Button,
11 | FormHelperText,
12 | Grid,
13 | IconButton,
14 | InputAdornment,
15 | InputLabel,
16 | Link,
17 | OutlinedInput,
18 | Stack,
19 | Typography,
20 | } from "@mui/material";
21 | import { Formik } from "formik";
22 | import AuthButtonAnimation from "../../../animations/authButtonAnimations";
23 | import Spinner from "../../../components/Spinner";
24 | import useTitle from "../../../hooks/useTitle";
25 | import { useLoginUserMutation } from "../authApiSlice";
26 | import { logIn } from "../authSlice";
27 |
28 | const LoginForm = () => {
29 | useTitle("Login - MERN Invoice");
30 |
31 | const navigate = useNavigate();
32 | const dispatch = useDispatch();
33 | const location = useLocation();
34 |
35 | const from = location.state?.from?.pathname || "/dashboard";
36 |
37 | const [showPassword, setShowPassword] = useState(false);
38 |
39 | const handleShowHidePassword = () => {
40 | setShowPassword(!showPassword);
41 | };
42 |
43 | const handleMouseDownPassword = (event) => {
44 | event.preventDefault();
45 | };
46 |
47 | const [loginUser, { data, isLoading, isSuccess }] = useLoginUserMutation();
48 |
49 | useEffect(() => {
50 | if (isSuccess) {
51 | navigate(from, { replace: true });
52 | }
53 | }, [data, isSuccess, navigate, from]);
54 |
55 | return (
56 | <>
57 | {
73 | try {
74 | const getUserCredentials = await loginUser(
75 | values
76 | ).unwrap();
77 | dispatch(logIn({ ...getUserCredentials }));
78 | setStatus({ success: true });
79 | setSubmitting(false);
80 | } catch (err) {
81 | const message = err.data.message;
82 | toast.error(message);
83 | setStatus({ success: false });
84 | setSubmitting(false);
85 | }
86 | }}
87 | >
88 | {({
89 | errors,
90 | handleBlur,
91 | handleChange,
92 | handleSubmit,
93 | isSubmitting,
94 | touched,
95 | values,
96 | }) => (
97 |
231 | )}
232 |
233 | >
234 | );
235 | };
236 |
237 | export default LoginForm;
238 |
--------------------------------------------------------------------------------
/client/src/features/auth/pages/LoginPage.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | Container,
5 | Divider,
6 | Grid,
7 | Link,
8 | Typography,
9 | } from "@mui/material";
10 | import { FaSignInAlt } from "react-icons/fa";
11 | import { Link as RouterLink } from "react-router-dom";
12 | import GoogleLogin from "../../../components/GoogleLogin";
13 | import StyledDivider from "../../../components/StyledDivider";
14 | import AuthWrapper from "../forms/AuthWrapper";
15 | import LoginForm from "../forms/LoginForm";
16 |
17 | const LoginPage = () => {
18 | return (
19 |
20 |
29 |
30 |
31 |
39 |
40 | Log In
41 |
42 |
43 |
44 | {/* login form */}
45 |
46 | {/* or sign in with Google */}
47 |
48 |
55 |
59 |
74 | OR LOG In WITH GOOGLE
75 |
76 |
80 |
81 |
88 |
89 |
90 |
91 |
95 | {/* forgot password */}
96 |
97 |
104 |
105 | Don't have an account?
106 |
112 | Sign Up Here
113 |
114 |
115 |
116 |
117 |
121 | {/* resend email verification button */}
122 |
123 |
130 |
131 | Didn't get the verification email?
132 |
138 | Resend Email
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 | );
147 | };
148 |
149 | export default LoginPage;
150 |
--------------------------------------------------------------------------------
/client/src/features/auth/pages/PasswordResetRequestPage.jsx:
--------------------------------------------------------------------------------
1 | import SendIcon from "@mui/icons-material/Send";
2 | import {
3 | Box,
4 | Button,
5 | Container,
6 | FormHelperText,
7 | Grid,
8 | InputLabel,
9 | OutlinedInput,
10 | Stack,
11 | Typography,
12 | } from "@mui/material";
13 | import { Formik } from "formik";
14 | import { useEffect } from "react";
15 | import { GoMailRead } from "react-icons/go";
16 | import { useNavigate } from "react-router-dom";
17 | import { toast } from "react-toastify";
18 | import * as Yup from "yup";
19 | import Spinner from "../../../components/Spinner";
20 | import StyledDivider from "../../../components/StyledDivider";
21 | import useTitle from "../../../hooks/useTitle";
22 | import AuthWrapper from "../forms/AuthWrapper";
23 | import { usePasswordResetRequestMutation } from "../authApiSlice";
24 |
25 | const PasswordResetRequestPage = () => {
26 | useTitle("Request Reset Password");
27 | const navigate = useNavigate();
28 | // -1 means go back to the previous page where you came from
29 | const goBack = () => navigate(-1);
30 |
31 | const [passwordResetRequest, { data, isLoading, isSuccess }] =
32 | usePasswordResetRequestMutation();
33 |
34 | useEffect(() => {
35 | if (isSuccess) {
36 | navigate("/login");
37 | const message = data.message;
38 | toast.success(message);
39 | }
40 | }, [data, isSuccess, navigate]);
41 |
42 | return (
43 | <>
44 | {
53 | try {
54 | await passwordResetRequest(values).unwrap();
55 | setStatus({ success: true });
56 | setSubmitting(false);
57 | } catch (err) {
58 | const message = err.data.message;
59 | toast.error(message);
60 | setStatus({ success: false });
61 | setSubmitting(false);
62 | }
63 | }}
64 | >
65 | {({
66 | errors,
67 | handleBlur,
68 | handleChange,
69 | handleSubmit,
70 | isSubmitting,
71 | touched,
72 | values,
73 | }) => (
74 |
75 |
84 |
190 |
191 |
192 | )}
193 |
194 | >
195 | );
196 | };
197 |
198 | export default PasswordResetRequestPage;
199 |
--------------------------------------------------------------------------------
/client/src/features/auth/pages/RegisterPage.jsx:
--------------------------------------------------------------------------------
1 | import LockOpenIcon from "@mui/icons-material/LockOpen";
2 | import {
3 | Box,
4 | Button,
5 | Container,
6 | Divider,
7 | Typography,
8 | Grid,
9 | } from "@mui/material";
10 | import { FaUserCheck } from "react-icons/fa";
11 | import { Link } from "react-router-dom";
12 | import GoogleLogin from "../../../components/GoogleLogin";
13 | import StyledDivider from "../../../components/StyledDivider";
14 | import AuthWrapper from "../forms/AuthWrapper";
15 | import RegisterForm from "../forms/RegisterForm";
16 |
17 | const RegisterPage = () => (
18 |
19 |
28 |
29 |
30 |
38 |
39 | Sign Up
40 |
41 |
42 |
43 | {/* registration form */}
44 |
45 |
46 | {/* already have an account link */}
47 |
60 | }
62 | endIcon={ }
63 | >
64 |
71 | Already have an account?
72 |
73 |
74 |
75 | {/* or sign up with google */}
76 |
77 |
78 |
82 |
97 | OR SIGN UP WITH GOOGLE
98 |
99 |
103 |
104 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | );
118 |
119 | export default RegisterPage;
120 |
--------------------------------------------------------------------------------
/client/src/features/auth/pages/ResendEmailTokenPage.jsx:
--------------------------------------------------------------------------------
1 | import SendIcon from "@mui/icons-material/Send";
2 | import {
3 | Box,
4 | Button,
5 | Container,
6 | FormHelperText,
7 | Grid,
8 | InputLabel,
9 | OutlinedInput,
10 | Stack,
11 | Typography,
12 | } from "@mui/material";
13 | import { Formik } from "formik";
14 | import { useEffect } from "react";
15 | import { MdOutgoingMail } from "react-icons/md";
16 | import { useNavigate } from "react-router-dom";
17 | import { toast } from "react-toastify";
18 | import * as Yup from "yup";
19 | import Spinner from "../../../components/Spinner";
20 | import StyledDivider from "../../../components/StyledDivider";
21 | import useTitle from "../../../hooks/useTitle";
22 | import { useResendVerifyEmailMutation } from "../authApiSlice";
23 | import AuthWrapper from "../forms/AuthWrapper";
24 |
25 | const ResendEmailTokenPage = () => {
26 | useTitle("Resend Verification Email");
27 |
28 | const navigate = useNavigate();
29 | const goBack = () => navigate(-1);
30 |
31 | const [resendVerifyEmail, { data, isLoading, isSuccess }] =
32 | useResendVerifyEmailMutation();
33 |
34 | useEffect(() => {
35 | if (isSuccess) {
36 | navigate("/");
37 | const message = data.message;
38 | toast.success(message);
39 | }
40 | }, [data, isSuccess, navigate]);
41 |
42 | return (
43 | <>
44 | {
53 | try {
54 | await resendVerifyEmail(values).unwrap();
55 |
56 | setStatus({ success: true });
57 | setSubmitting(false);
58 | } catch (err) {
59 | const message = err.data.message;
60 | toast.error(message);
61 | setStatus({ success: false });
62 | setSubmitting(false);
63 | }
64 | }}
65 | >
66 | {({
67 | errors,
68 | handleBlur,
69 | handleChange,
70 | handleSubmit,
71 | isSubmitting,
72 | touched,
73 | values,
74 | }) => (
75 |
76 |
85 |
173 |
174 |
175 | )}
176 |
177 | >
178 | );
179 | };
180 |
181 | export default ResendEmailTokenPage;
182 |
--------------------------------------------------------------------------------
/client/src/features/auth/pages/VerifiedPage.jsx:
--------------------------------------------------------------------------------
1 | import LockOpenIcon from "@mui/icons-material/LockOpen";
2 | import { Button, Stack, Typography } from "@mui/material";
3 | import { FaCheckCircle } from "react-icons/fa";
4 | import { Link } from "react-router-dom";
5 | import useTitle from "../../../hooks/useTitle";
6 |
7 | const VerifiedPage = () => {
8 | useTitle("Verify User - MERN Invoice");
9 | return (
10 |
17 |
18 |
19 | Account Verified
20 |
21 |
22 |
23 | Your Account has been verified and is ready for use.
24 |
25 |
26 |
27 | An Email to confirm the same has been sent
28 |
29 | } endIcon={ }>
30 |
36 | Please login to use our service
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | export default VerifiedPage;
44 |
--------------------------------------------------------------------------------
/client/src/features/customers/customersApiSlice.js:
--------------------------------------------------------------------------------
1 | import { baseApiSlice } from "../api/baseApiSlice";
2 |
3 | export const customersApiSlice = baseApiSlice.injectEndpoints({
4 | endpoints: (builder) => ({
5 | getAllUserCustomers: builder.query({
6 | query: (page = 1) => `/customer/all?page=${page}`,
7 | providesTags: ["Customer"],
8 | }),
9 | createCustomer: builder.mutation({
10 | query: (customerInfo) => ({
11 | url: "/customer/create",
12 | method: "POST",
13 | body: customerInfo,
14 | }),
15 | invalidatesTags: ["Customer"],
16 | }),
17 | getSingleCustomer: builder.query({
18 | query: (custId) => `/customer/${custId}`,
19 | providesTags: ["Customer"],
20 | }),
21 | updateCustomerInfo: builder.mutation({
22 | query: ({ id, ...otherFields }) => ({
23 | url: `/customer/${id}`,
24 | method: "PATCH",
25 | body: otherFields,
26 | }),
27 | invalidatesTags: ["Customer"],
28 | }),
29 | deleteCustomer: builder.mutation({
30 | query: (id) => ({
31 | url: `/customer/${id}`,
32 | method: "DELETE",
33 | }),
34 | invalidatesTags: ["Customer"],
35 | }),
36 | }),
37 | });
38 |
39 | export const {
40 | useGetAllUserCustomersQuery,
41 | useGetSingleCustomerQuery,
42 | useCreateCustomerMutation,
43 | useUpdateCustomerInfoMutation,
44 | useDeleteCustomerMutation,
45 | } = customersApiSlice;
46 |
--------------------------------------------------------------------------------
/client/src/features/customers/pages/CustomerEditForm.jsx:
--------------------------------------------------------------------------------
1 | import CheckIcon from "@mui/icons-material/Check";
2 | import {
3 | Box,
4 | Button,
5 | Container,
6 | CssBaseline,
7 | Grid,
8 | Stack,
9 | TextField,
10 | Typography,
11 | } from "@mui/material";
12 | import { useEffect, useState } from "react";
13 | import { GrDocumentUpdate } from "react-icons/gr";
14 | import { useNavigate, useParams } from "react-router-dom";
15 | import { toast } from "react-toastify";
16 | import Spinner from "../../../components/Spinner";
17 | import StyledDivider from "../../../components/StyledDivider";
18 | import {
19 | useGetSingleCustomerQuery,
20 | useUpdateCustomerInfoMutation,
21 | } from "../customersApiSlice";
22 |
23 | const CustomerEditForm = () => {
24 | const { custId } = useParams();
25 |
26 | const [name, setName] = useState("");
27 | const [email, setEmail] = useState("");
28 | const [phoneNumber, setPhoneNumber] = useState("");
29 | const [vatTinNo, setVatTinNo] = useState(0);
30 | const [address, setAddress] = useState("");
31 | const [city, setCity] = useState("");
32 | const [country, setCountry] = useState("");
33 |
34 | const navigate = useNavigate();
35 | // -1 means go back to the previous page where you came from
36 | const goBack = () => navigate(-1);
37 |
38 | const { data } = useGetSingleCustomerQuery(custId);
39 |
40 | const [updateCustomerInfo, { isLoading, isSuccess, data: updateData }] =
41 | useUpdateCustomerInfoMutation();
42 |
43 | useEffect(() => {
44 | const customer = data?.customer;
45 | if (customer) {
46 | setName(customer.name);
47 | setEmail(customer.email);
48 | setPhoneNumber(customer.phoneNumber);
49 | setVatTinNo(customer.vatTinNo);
50 | setAddress(customer.address);
51 | setCity(customer.city);
52 | setCountry(customer.country);
53 | }
54 | }, [data]);
55 |
56 | useEffect(() => {
57 | if (isSuccess) {
58 | navigate("/customers");
59 | const message = updateData?.message;
60 | toast.success(message);
61 | }
62 | }, [isSuccess, navigate, updateData]);
63 |
64 | const updateHandler = async (e) => {
65 | e.preventDefault();
66 |
67 | try {
68 | const userData = {
69 | name,
70 | email,
71 | phoneNumber,
72 | vatTinNo,
73 | address,
74 | city,
75 | country,
76 | };
77 | await updateCustomerInfo({
78 | id: data?.customer._id,
79 | ...userData,
80 | }).unwrap();
81 | } catch (err) {
82 | console.error(err);
83 | }
84 | };
85 |
86 | return (
87 |
97 |
98 |
99 |
100 |
105 |
106 |
107 | Edit Customer Info
108 |
109 |
119 | Go Back
120 |
121 |
122 |
123 |
124 | {isLoading ? (
125 |
126 | ) : (
127 |
139 |
140 |
141 | {/* customer name */}
142 | setName(e.target.value)}
151 | />
152 |
153 |
154 | {/* customer Email */}
155 | setEmail(e.target.value)}
164 | />
165 |
166 |
167 | {/* Phone Number */}
168 | setPhoneNumber(e.target.value)}
177 | />
178 |
179 |
180 | {/* VAT/TIN Number */}
181 | setVatTinNo(e.target.value)}
189 | />
190 |
191 |
192 | {/* Address */}
193 | setAddress(e.target.value)}
201 | />
202 |
203 |
204 | {/* City */}
205 | setCity(e.target.value)}
213 | />
214 |
215 |
216 | {/* Country */}
217 | setCountry(e.target.value)}
225 | />
226 | }
234 | >
235 | Update Customer
236 |
237 |
238 | )}
239 |
240 | );
241 | };
242 |
243 | export default CustomerEditForm;
244 |
--------------------------------------------------------------------------------
/client/src/features/customers/pages/CustomerSVG.jsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid, Stack, Typography } from "@mui/material";
2 | import CustomerSvg from "../../../images/add_customer.svg";
3 | import "../../../styles/customer-button.css";
4 |
5 | const CustomerSVG = () => {
6 | return (
7 |
8 |
9 |
10 |
15 |
16 | Sadly, You have no customers yet. To get started,
17 | click on 👉 👉 👉
18 |
19 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default CustomerSVG;
32 |
--------------------------------------------------------------------------------
/client/src/features/customers/pages/SingleCustomerPage.jsx:
--------------------------------------------------------------------------------
1 | import AttachEmailIcon from "@mui/icons-material/AttachEmail";
2 | import BadgeIcon from "@mui/icons-material/Badge";
3 | import CottageTwoToneIcon from "@mui/icons-material/CottageTwoTone";
4 | import EditTwoToneIcon from "@mui/icons-material/EditTwoTone";
5 | import NumbersTwoToneIcon from "@mui/icons-material/NumbersTwoTone";
6 | import PermPhoneMsgTwoToneIcon from "@mui/icons-material/PermPhoneMsgTwoTone";
7 | import PushPinTwoToneIcon from "@mui/icons-material/PushPinTwoTone";
8 | import RequestQuoteTwoToneIcon from "@mui/icons-material/RequestQuoteTwoTone";
9 | import VpnLockTwoToneIcon from "@mui/icons-material/VpnLockTwoTone";
10 | import {
11 | Box,
12 | Container,
13 | Button,
14 | List,
15 | CssBaseline,
16 | ListItem,
17 | ListItemIcon,
18 | ListItemText,
19 | Stack,
20 | Typography,
21 | } from "@mui/material";
22 |
23 | import { GrUser } from "react-icons/gr";
24 | import { useNavigate, useParams } from "react-router-dom";
25 | import Spinner from "../../../components/Spinner";
26 | import StyledDivider from "../../../components/StyledDivider";
27 | import { useGetSingleCustomerQuery } from "../customersApiSlice";
28 |
29 | function capitalizeFirstLetter(string) {
30 | return string.charAt(0).toUpperCase() + string.slice(1);
31 | }
32 |
33 | const SingleCustomerPage = () => {
34 | const { custId } = useParams();
35 |
36 | const navigate = useNavigate();
37 |
38 | const goBack = () => navigate(-1);
39 |
40 | const { data, isLoading } = useGetSingleCustomerQuery(custId);
41 |
42 | return (
43 |
53 |
54 |
62 |
63 |
64 | {data?.customer.name.split(" ")[0]}'s Info
65 |
66 |
67 |
74 | Go Back
75 |
76 |
77 |
78 | {isLoading ? (
79 |
80 | ) : (
81 |
88 |
94 |
95 | {/* name` */}
96 |
97 |
98 |
99 |
100 |
105 |
106 | {/* email` */}
107 |
108 |
109 |
110 |
111 |
112 |
113 | {/* account No` */}
114 |
115 |
116 |
117 |
118 |
121 |
122 | {/* VAT/TIN No` */}
123 |
124 |
125 |
126 |
127 |
134 |
135 |
136 |
137 | {/* second list */}
138 |
139 | {/*address */}
140 |
141 |
142 |
143 |
144 |
151 |
152 |
153 | {/*city */}
154 |
155 |
156 |
157 |
158 |
165 |
166 |
167 | {/*country */}
168 |
169 |
170 |
171 |
172 |
179 |
180 |
181 | {/*phone */}
182 |
183 |
184 |
185 |
186 |
193 |
194 |
195 |
196 |
197 |
198 | }
205 | onClick={() =>
206 | navigate(`/edit-customer/${data?.customer._id}`)
207 | }
208 | >
209 |
210 | Edit Customer Info
211 |
212 |
213 |
214 |
215 | )}
216 |
217 | );
218 | };
219 |
220 | export default SingleCustomerPage;
221 |
--------------------------------------------------------------------------------
/client/src/features/dashboard/pages/components/paymentHistory.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Paper,
3 | Table,
4 | TableContainer,
5 | TableBody,
6 | TableCell,
7 | TableFooter,
8 | TableHead,
9 | TablePagination,
10 | TableRow,
11 | } from "@mui/material";
12 | import moment from "moment";
13 | import { useState } from "react";
14 | import StyledTableCell from "../../../../components/StyledTableCell";
15 | import StyledTableRow from "../../../../components/StyledTableRow";
16 | import TablePaginationActions from "../../../../components/TablePaginationActions";
17 | import { addCurrencyCommas } from "../../../documents/pages/components/addCurrencyCommas";
18 |
19 | const PaymentHistory = ({ sortPaymentHistory }) => {
20 | const [page, setPage] = useState(0);
21 | const [rowsPerPage, setRowsPerPage] = useState(5);
22 |
23 | const rows = sortPaymentHistory;
24 |
25 | // Avoid a layout jump when reaching the last page with empty rows.
26 | const emptyRows =
27 | page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows?.length) : 0;
28 |
29 | const handleChangePage = (event, newPage) => {
30 | setPage(newPage);
31 | };
32 |
33 | const handleChangeRowsPerPage = (event) => {
34 | setRowsPerPage(parseInt(event.target.value, 10));
35 | setPage(0);
36 | };
37 |
38 | return (
39 |
43 |
44 |
45 |
46 | #
47 | Paid By
48 | Date Paid
49 | Amount Paid
50 | Payment Method
51 | Additional Info
52 |
53 |
54 |
55 |
56 | {(rowsPerPage > 0
57 | ? rows.slice(
58 | page * rowsPerPage,
59 | page * rowsPerPage + rowsPerPage
60 | )
61 | : rows
62 | ).map((row, index) => (
63 |
72 |
73 | {page * rowsPerPage + index + 1}
74 |
75 | {row?.paidBy}
76 |
77 | {moment(row?.datePaid).format("DD-MM-YYYY")}
78 |
79 |
80 | {addCurrencyCommas(row?.amountPaid)}
81 |
82 |
83 | {row?.paymentMethod}
84 |
85 |
86 | {row?.additionalInfo
87 | ? row?.additionalInfo
88 | : "No additional info"}
89 |
90 |
91 | ))}
92 |
93 | {/* control how empty rows shall be displayed should they exist*/}
94 | {emptyRows > 0 && (
95 |
96 |
97 |
98 | )}
99 |
100 |
101 |
102 |
123 |
124 |
125 |
126 |
127 | );
128 | };
129 |
130 | export default PaymentHistory;
131 |
--------------------------------------------------------------------------------
/client/src/features/documents/documentsApiSlice.js:
--------------------------------------------------------------------------------
1 | import { baseApiSlice } from "../api/baseApiSlice";
2 |
3 | export const documentsApiSlice = baseApiSlice.injectEndpoints({
4 | endpoints: (builder) => ({
5 | getAllMyDocs: builder.query({
6 | query: (page = 1) => `/document/all?page=${page}`,
7 | providesTags: ["Document"],
8 | }),
9 | getSingleDoc: builder.query({
10 | query: (id) => `/document/${id}`,
11 | providesTags: ["Document"],
12 | }),
13 | createDoc: builder.mutation({
14 | query: (formData) => ({
15 | url: "/document/create",
16 | method: "POST",
17 | body: formData,
18 | }),
19 | invalidatesTags: ["Document"],
20 | }),
21 | updateDoc: builder.mutation({
22 | query: ({ id, ...rest }) => ({
23 | url: `/document/${id}`,
24 | method: "PATCH",
25 | body: rest,
26 | }),
27 | invalidatesTags: ["Document"],
28 | }),
29 | deleteDoc: builder.mutation({
30 | query: (id) => ({
31 | url: `/document/${id}`,
32 | method: "DELETE",
33 | }),
34 | invalidatesTags: ["Document"],
35 | }),
36 | createPayment: builder.mutation({
37 | query: ({ id, ...rest }) => ({
38 | url: `/document/${id}/payment`,
39 | method: "POST",
40 | body: rest,
41 | }),
42 | invalidatesTags: ["Document"],
43 | }),
44 | }),
45 | });
46 |
47 | export const {
48 | useCreatePaymentMutation,
49 | useDeleteDocMutation,
50 | useCreateDocMutation,
51 | useGetSingleDocQuery,
52 | useGetAllMyDocsQuery,
53 | useUpdateDocMutation,
54 | } = documentsApiSlice;
55 |
--------------------------------------------------------------------------------
/client/src/features/documents/pages/PaymentForm.jsx:
--------------------------------------------------------------------------------
1 | import PaidIcon from "@mui/icons-material/Paid";
2 | import {
3 | Autocomplete,
4 | Box,
5 | TextField,
6 | Typography,
7 | Button,
8 | } from "@mui/material";
9 | import { toast } from "react-toastify";
10 | import Spinner from "../../../components/Spinner";
11 | import { useEffect, useState } from "react";
12 | import PaymentDate from "./components/PaymentDate";
13 |
14 | import {
15 | useCreatePaymentMutation,
16 | useUpdateDocMutation,
17 | } from "../documentsApiSlice";
18 |
19 | const PaymentForm = ({ document }) => {
20 | const [createPayment, { isLoading, isSuccess, data }] =
21 | useCreatePaymentMutation();
22 |
23 | const [updateDoc] = useUpdateDocMutation();
24 |
25 | const paymentOptions = [
26 | "Mobile Money",
27 | "Cash",
28 | "Bank Transfer",
29 | "PayPal",
30 | "Credit Card",
31 | "Others",
32 | ];
33 |
34 | const [datePaid, setDatePaid] = useState(new Date());
35 | const [paymentMethod, setPaymentMethod] = useState("");
36 | const [additionalInfo, setAdditionalInfo] = useState("");
37 | const [amountPaid, setAmountPaid] = useState(0);
38 | const [totalAmountReceived, setTotalAmountReceived] = useState(0);
39 |
40 | useEffect(() => {
41 | if (isSuccess) {
42 | const message = data.message;
43 | toast.success(message);
44 | setAmountPaid(0);
45 | setPaymentMethod("");
46 | setAdditionalInfo("");
47 | }
48 | }, [data, isSuccess]);
49 |
50 | useEffect(() => {
51 | let totalReceived = 0;
52 | for (var i = 0; i < document?.paymentRecords?.length; i++) {
53 | totalReceived += Number(document?.paymentRecords[i]?.amountPaid);
54 | setTotalAmountReceived(totalReceived);
55 | }
56 | }, [document, totalAmountReceived]);
57 |
58 | const paymentHandler = async (e) => {
59 | e.preventDefault();
60 |
61 | try {
62 | await createPayment({
63 | id: document._id,
64 | paidBy: document.customer.name,
65 | datePaid,
66 | paymentMethod,
67 | additionalInfo,
68 | amountPaid,
69 | }).unwrap();
70 |
71 | await updateDoc({
72 | id: document._id,
73 | totalAmountReceived:
74 | Number(totalAmountReceived) + Number(amountPaid),
75 | status:
76 | Number(totalAmountReceived) + Number(amountPaid) >=
77 | document?.total
78 | ? "Paid"
79 | : "Not Fully Paid",
80 | }).unwrap();
81 | } catch (err) {
82 | const message = err.data.message;
83 | toast.error(message);
84 | }
85 | };
86 |
87 | return (
88 |
100 | {isLoading ? (
101 |
102 | ) : (
103 | <>
104 |
108 | setAmountPaid(e.target.value)}
116 | value={amountPaid}
117 | />
118 |
119 | (
123 |
128 | )}
129 | onChange={(event, value) => setPaymentMethod(value)}
130 | />
131 |
132 | setAdditionalInfo(e.target.value)}
140 | value={additionalInfo}
141 | />
142 | >
143 | )}
144 |
145 | }
153 | disabled={!amountPaid || !paymentMethod ? true : false}
154 | >
155 | Record Payment
156 |
157 |
158 | );
159 | };
160 |
161 | export default PaymentForm;
162 |
--------------------------------------------------------------------------------
/client/src/features/documents/pages/components/DocumentSVG.jsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid, Stack, Typography } from "@mui/material";
2 | import DocSVG from "../../../../images/add_bill.svg";
3 |
4 | const DocumentSVG = () => {
5 | return (
6 |
7 |
8 |
9 |
14 |
15 | Sadly, You have no Documents yet. To create on click
16 | 👉 👉 👉
17 |
18 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default DocumentSVG;
31 |
--------------------------------------------------------------------------------
/client/src/features/documents/pages/components/DocumentType.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Input, styled } from "@mui/material";
2 | import FormControl from "@mui/material/FormControl";
3 | import FormHelperText from "@mui/material/FormHelperText";
4 | import MenuItem from "@mui/material/MenuItem";
5 | import Select from "@mui/material/Select";
6 |
7 | const StyledSelect = styled(Select)({
8 | fontSize: "2rem",
9 | textTransform: "uppercase",
10 | fontWeight: "bold",
11 | });
12 |
13 | const DocumentType = ({ documentType, setDocumentType }) => {
14 | const handleChange = (e) => {
15 | setDocumentType(e.target.value);
16 | };
17 | return (
18 |
19 |
20 | }
22 | labelId="doc-helper-label"
23 | id="doc-select-helper"
24 | value={documentType}
25 | label="Select Document"
26 | onChange={handleChange}
27 | >
28 |
29 | Select Document Type
30 |
31 | Invoice
32 | Receipt
33 | Quotation
34 |
35 |
36 | Select a Document to generate, defaults to Invoice
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | export default DocumentType;
44 |
--------------------------------------------------------------------------------
/client/src/features/documents/pages/components/PaymentDate.jsx:
--------------------------------------------------------------------------------
1 | import { TextField } from "@mui/material";
2 | import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
3 | import { DesktopDatePicker } from "@mui/x-date-pickers/DesktopDatePicker";
4 | import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
5 |
6 | const PaymentDate = ({ datePaid, setDatePaid }) => {
7 | const handleChange = (date) => {
8 | setDatePaid(date.toISOString());
9 | };
10 | return (
11 |
12 | (
18 |
23 | )}
24 | />
25 |
26 | );
27 | };
28 |
29 | export default PaymentDate;
30 |
--------------------------------------------------------------------------------
/client/src/features/documents/pages/components/addCurrencyCommas.jsx:
--------------------------------------------------------------------------------
1 | export function addCurrencyCommas(currency) {
2 | if (currency) {
3 | return currency.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/client/src/features/documents/pages/components/styling.js:
--------------------------------------------------------------------------------
1 | export const statusStyling = (status) => {
2 | return status === "Paid"
3 | ? {
4 | border: "solid 1px black",
5 | backgroundColor: "#76ff03",
6 | padding: "8px 18px",
7 | borderRadius: "20px",
8 | }
9 | : status === "Not Fully Paid"
10 | ? {
11 | border: "solid 1px black",
12 | backgroundColor: "#b71c1c",
13 | color: "#FFFFFF",
14 | padding: "8px 18px",
15 | borderRadius: "20px",
16 | }
17 | : {
18 | border: "solid 1px black",
19 | backgroundColor: "#b71c1c",
20 | color: "#FFFFFF",
21 | padding: "8px 18px",
22 | borderRadius: "20px",
23 | };
24 | };
25 |
26 | export const DocumentTypeStyling = (documentType) => {
27 | return documentType === "Receipt"
28 | ? {
29 | border: "solid 1px black",
30 | backgroundColor: "#76ff03",
31 | padding: "8px 18px",
32 | borderRadius: "20px",
33 | }
34 | : {
35 | border: "solid 1px black",
36 | backgroundColor: "#2196f3",
37 | color: "#FFFFFF",
38 | padding: "8px 18px",
39 | borderRadius: "20px",
40 | };
41 | };
42 |
43 | export const statusColor = (totalAmountReceived, status) => {
44 | return totalAmountReceived >= document?.total
45 | ? "#ff9100"
46 | : status === "Paid"
47 | ? "green"
48 | : status === "Not Paid"
49 | ? "red"
50 | : "red";
51 | };
52 |
--------------------------------------------------------------------------------
/client/src/features/documents/pages/initialState.jsx:
--------------------------------------------------------------------------------
1 | export const docInitialState = {
2 | status: "Not Paid",
3 | additionalInfo: "",
4 | termsConditions: "",
5 | total: 0,
6 | salesTax: 0,
7 | rates: "",
8 | currency: "",
9 | customer: "",
10 | };
11 |
12 | export const itemsInitialState = [
13 | {
14 | name: "",
15 | unitPrice: "",
16 | quantity: "",
17 | discount: "",
18 | },
19 | ];
20 |
21 | export const paymentInitialState = [
22 | {
23 | paymentDate: new Date(),
24 | paidBy: "",
25 | amountPaid: 0,
26 | paymentMethod: "",
27 | additionalInfo: "",
28 | },
29 | ];
30 |
--------------------------------------------------------------------------------
/client/src/features/users/usersApiSlice.js:
--------------------------------------------------------------------------------
1 | import { baseApiSlice } from "../api/baseApiSlice";
2 |
3 | export const usersApiSlice = baseApiSlice.injectEndpoints({
4 | endpoints: (builder) => ({
5 | getAllUsers: builder.query({
6 | query: () => ({
7 | url: "/user/all",
8 | validateStatus: (response, result) => {
9 | return response.status === 200 && !result.isError;
10 | },
11 | }),
12 | providesTags: (result) =>
13 | result
14 | ? [
15 | ...result.users.map(({ id }) => ({
16 | type: "User",
17 | id,
18 | })),
19 | { type: "User", id: "LIST" },
20 | ]
21 | : [{ type: "User", id: "LIST" }],
22 | }),
23 | getUserProfile: builder.query({
24 | query: () => "/user/profile",
25 | providesTags: [{ type: "User", id: "SINGLE_USER" }],
26 | }),
27 | updateUserProfile: builder.mutation({
28 | query: (profileData) => ({
29 | url: "/user/profile",
30 | method: "PATCH",
31 | body: profileData,
32 | }),
33 | invalidatesTags: [{ type: "User", id: "SINGLE_USER" }],
34 | }),
35 | deleteMyAccount: builder.mutation({
36 | query: () => ({
37 | url: "user/profile",
38 | method: "DELETE",
39 | }),
40 | invalidatesTags: [{ type: "User", id: "LIST" }],
41 | }),
42 | deleteUser: builder.mutation({
43 | query: (id) => ({
44 | url: `/user/${id}`,
45 | method: "DELETE",
46 | }),
47 | invalidatesTags: [{ type: "User", id: "LIST" }],
48 | }),
49 | deactivateUser: builder.mutation({
50 | query: (id) => ({
51 | url: `/user/${id}/deactivate`,
52 | method: "PATCH",
53 | }),
54 | invalidatesTags: [{ type: "User", id: "LIST" }],
55 | }),
56 | }),
57 | });
58 |
59 | export const {
60 | useGetAllUsersQuery,
61 | useUpdateUserProfileMutation,
62 | useGetUserProfileQuery,
63 | useDeleteMyAccountMutation,
64 | useDeleteUserMutation,
65 | useDeactivateUserMutation,
66 | } = usersApiSlice;
67 |
--------------------------------------------------------------------------------
/client/src/hooks/useAuthUser.jsx:
--------------------------------------------------------------------------------
1 | import { decodeToken } from "react-jwt";
2 | import { useSelector } from "react-redux";
3 | import {
4 | selectCurrentUserToken,
5 | selectCurrentUserGoogleToken,
6 | } from "../features/auth/authSlice";
7 |
8 | const useAuthUser = () => {
9 | const token = useSelector(selectCurrentUserToken);
10 | const googleToken = useSelector(selectCurrentUserGoogleToken);
11 |
12 | let isAdmin = false;
13 |
14 | let accessRight = "User";
15 |
16 | if (token) {
17 | const decodedToken = decodeToken(token);
18 | const { roles } = decodedToken;
19 | isAdmin = roles.includes("Admin");
20 |
21 | if (isAdmin) accessRight = "Admin";
22 |
23 | return { roles, isAdmin, accessRight };
24 | } else if (googleToken) {
25 | const gDecodedToken = decodeToken(googleToken);
26 | const { roles } = gDecodedToken;
27 |
28 | isAdmin = roles.includes("Admin");
29 |
30 | if (isAdmin) accessRight = "Admin";
31 |
32 | return { roles, isAdmin, accessRight };
33 | }
34 |
35 | return { roles: [], isAdmin, accessRight };
36 | };
37 |
38 | export default useAuthUser;
39 |
--------------------------------------------------------------------------------
/client/src/hooks/useTitle.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | const useTitle = (title) => {
4 | useEffect(() => {
5 | const prevTitle = document.title;
6 | document.title = title;
7 |
8 | return () => (document.title = prevTitle);
9 | }, [title]);
10 | };
11 |
12 | export default useTitle;
13 |
--------------------------------------------------------------------------------
/client/src/images/add_bill.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/images/buildings.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/src/images/buildings.jpg
--------------------------------------------------------------------------------
/client/src/images/googleLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/src/images/googleLogo.png
--------------------------------------------------------------------------------
/client/src/images/profile_default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/client/src/images/profile_default.png
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | font-family: "Roboto", sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | min-height: 100vh;
9 | }
10 |
11 | .footer {
12 | position: fixed;
13 | width: 100%;
14 | bottom: 0;
15 | }
16 |
17 | .auth-svg {
18 | font-size: 6rem;
19 | }
20 |
21 | .verified {
22 | color: limegreen;
23 | font-size: 4rem;
24 | margin-top: -25px;
25 | }
26 | .google-icon {
27 | font-size: 4rem;
28 | }
29 |
30 | .broken-heart {
31 | font-size: 10rem;
32 | color: crimson;
33 | }
34 | .sad-tear {
35 | font-size: 10rem;
36 | color: rgb(158, 158, 13);
37 | }
38 |
39 | /* document styling */
40 | .title {
41 | background-color: rgba(238, 235, 235, 0.954);
42 | border-bottom: 1px solid rgb(17, 65, 141);
43 | color: rgb(17, 65, 141);
44 | font-weight: 500;
45 | padding: 15px 0px;
46 | text-align: center;
47 | }
48 |
49 | .billItem {
50 | display: flex;
51 | align-items: center;
52 | justify-content: space-between;
53 | border-bottom: 1px solid rgb(17, 65, 141);
54 | }
55 | .billItem p,
56 | h4 {
57 | padding: 3px;
58 | }
59 | .toolBar {
60 | margin-top: 50px;
61 | }
62 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 | import { Provider } from "react-redux";
4 | import { store } from "./app/store";
5 | import App from "./App";
6 | import reportWebVitals from "./reportWebVitals";
7 | import { disableReactDevTools } from "@fvilers/disable-react-devtools";
8 | import "./index.css";
9 |
10 | import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
11 |
12 | if (process.env.NODE_ENV === "production") {
13 | disableReactDevTools();
14 | }
15 |
16 | const container = document.getElementById("root");
17 | const root = createRoot(container);
18 |
19 | root.render(
20 |
21 |
22 |
23 |
24 | } />
25 |
26 |
27 |
28 |
29 | );
30 |
31 | // If you want to start measuring performance in your app, pass a function
32 | // to log results (for example: reportWebVitals(console.log))
33 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
34 | reportWebVitals();
35 |
--------------------------------------------------------------------------------
/client/src/pages/HomePage.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Grid, Link, styled, Typography } from "@mui/material";
2 | import { Link as RouterLink, useNavigate } from "react-router-dom";
3 | import "../styles/homepage.css";
4 |
5 | const StyledTypography = styled(Typography)(({ theme }) => ({
6 | fontSize: "12rem",
7 | [theme.breakpoints.down("sm")]: {
8 | fontSize: "9rem",
9 | },
10 | }));
11 |
12 | const CreateAccountButton = styled(Button)({
13 | borderColor: "#000000",
14 | borderRadius: "25px",
15 | border: "3px solid",
16 | "&:hover": {
17 | borderColor: "#07f011",
18 | boxShadow: "none",
19 | border: "2px solid",
20 | },
21 | });
22 |
23 | const HomePage = () => {
24 | const navigate = useNavigate();
25 | return (
26 | <>
27 |
28 |
29 |
30 |
31 |
37 | MERN Invoice
38 |
39 |
46 | Whatever business you run, Creating
47 | Invoices,Receipts and Quotations is made easy
48 | with our app.
49 |
50 |
51 |
59 | navigate("/register")}
65 | >
66 |
75 | Create Account
76 |
77 |
78 |
79 |
80 |
81 |
82 | >
83 | );
84 | };
85 |
86 | export default HomePage;
87 |
--------------------------------------------------------------------------------
/client/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/client/src/styles/customer-button.css:
--------------------------------------------------------------------------------
1 | .new-customer-btn {
2 | /* margin-left: 100px; */
3 | box-shadow: 0 0 0 0 #f0f0f0, 0 0 0 0 rgba(227, 115, 14, 0.7);
4 | animation: pulse 1.25s infinite cubic-bezier(0.66, 0.33, 0, 1);
5 | -webkit-transition: all 600ms ease-in-out;
6 | -moz-transition: all 600ms ease-in-out;
7 | -ms-transition: all 600ms ease-in-out;
8 | -o-transition: all 600ms ease-in-out;
9 | transition: all 600ms ease-in-out;
10 | }
11 |
12 | .customer-svg {
13 | width: 60%;
14 | display: block;
15 | margin: auto;
16 | }
17 |
18 | @keyframes pulse {
19 | to {
20 | box-shadow: 0 0 0 12px transparent, 0 0 0 24px rgba(227, 115, 14, 0);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/styles/homepage.css:
--------------------------------------------------------------------------------
1 | .main-bg-image {
2 | background-attachment: scroll;
3 | background-color: initial;
4 | background-image: linear-gradient(
5 | rgba(0, 0, 0, 0.3) 0,
6 | rgba(0, 0, 0, 0.7) 75%,
7 | #000 100%
8 | ),
9 | url("../images/buildings.jpg");
10 | background-position: center center;
11 | background-repeat: no-repeat no-repeat;
12 | background-size: cover;
13 | }
14 | .masthead {
15 | box-sizing: border-box;
16 | color: #212529;
17 | font-family: Nunito, -apple-system, "system-ui", "Segoe UI", Roboto,
18 | "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
19 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
20 | height: 100vh;
21 | letter-spacing: 1px;
22 | line-height: 24px;
23 | min-height: 35rem;
24 | padding: 0;
25 | position: relative;
26 | width: 100%;
27 | }
28 | .homepage-header {
29 | -webkit-text-fill-color: transparent;
30 | background-clip: text;
31 | background-color: initial;
32 | background-image: linear-gradient(
33 | rgba(255, 255, 255, 0.9),
34 | rgba(255, 255, 255, 0)
35 | );
36 | box-sizing: border-box;
37 | color: #212529;
38 | font-size: 5rem;
39 | font-weight: 500;
40 | letter-spacing: 0.8rem;
41 | line-height: 2.5rem;
42 | margin: 0 auto;
43 | }
44 |
45 | @media only screen and (max-width: 600px) {
46 | .main-bg-image {
47 | width: 100%;
48 | height: 120vh;
49 | background-size: cover;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/client/src/styles/spinner.css:
--------------------------------------------------------------------------------
1 | .spinnerContainer {
2 | top: 0;
3 | right: 0;
4 | bottom: 0;
5 | left: 0;
6 | z-index: 5000;
7 | display: flex;
8 | align-items: center;
9 | justify-content: center;
10 | padding: 100px;
11 | }
12 |
13 | .spinner {
14 | width: 200px;
15 | height: 200px;
16 | border: 16px solid #f3f3f3;
17 | border-color: #000 transparent #1976d2 transparent;
18 | border-radius: 50%;
19 | animation: spin 1.2s linear infinite;
20 | }
21 |
22 | @keyframes spin {
23 | 0% {
24 | transform: rotate(0deg);
25 | }
26 | 100% {
27 | transform: rotate(360deg);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/utils/password-strength.js:
--------------------------------------------------------------------------------
1 | const hasNumber = (number) => new RegExp(/[0-9]/).test(number);
2 |
3 | const hasMixed = (number) =>
4 | new RegExp(/[a-z]/).test(number) && new RegExp(/[A-Z]/).test(number);
5 |
6 | const hasSpecial = (number) => new RegExp(/[!#@$%^&*)(+=._-]/).test(number);
7 |
8 | export const strengthColor = (count) => {
9 | if (count < 2) return { label: "Poor", color: "#FF1744" };
10 | if (count < 3) return { label: "Weak", color: "#FFEA00" };
11 | if (count < 4) return { label: "Normal", color: "#FFC400" };
12 | if (count < 5) return { label: "Good", color: "#52C41A" };
13 | if (count < 6) return { label: "Strong", color: "#C6FF00" };
14 | return { label: "Poor", color: "#ff4d4f" };
15 | };
16 |
17 | export const strengthIndicator = (number) => {
18 | let strengths = 0;
19 |
20 | if (number.length > 5) strengths += 1;
21 | if (number.length > 7) strengths += 1;
22 | if (hasNumber(number)) strengths += 1;
23 | if (hasSpecial(number)) strengths += 1;
24 | if (hasMixed(number)) strengths += 1;
25 |
26 | return strengths;
27 | };
28 |
--------------------------------------------------------------------------------
/docker/digital_ocean_server_deploy.sh:
--------------------------------------------------------------------------------
1 | #! /bin/bash
2 |
3 | if [ -z "$DIGITAL_OCEAN_IP_ADDRESS"]
4 | then
5 | echo "DIGITAL_OCEAN_IP_ADDRESS not defined"
6 | exit 0
7 | fi
8 |
9 | git archive --format tar --output ./project.tar main
10 |
11 | echo 'Uploading project.........:-)...Be Patient!'
12 | rsync ./project.tar root@$DIGITAL_OCEAN_IP_ADDRESS:/tmp/project.tar
13 | echo 'Upload complete'
14 |
15 | echo 'Building the image...'
16 | ssh -o StrictHostKeyChecking=no root@$DIGITAL_OCEAN_IP_ADDRESS << 'ENDSSH'
17 | mkdir -p /app
18 | rm -rf /app/* && tar -xf /tmp/project.tar -C /app
19 | docker-compose -f /app/production.yml build
20 | ENDSSH
21 | echo 'Build completed successfully.....:-)'
22 |
23 |
24 |
--------------------------------------------------------------------------------
/docker/local/express/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG NODE_VERSION=16-alpine3.12
2 |
3 | FROM node:${NODE_VERSION}
4 |
5 | LABEL name="mern-invoice"
6 | LABEL license="MIT"
7 | LABEL description="MERN invoice image"
8 |
9 | ENV NODE_ENV=development
10 |
11 | ARG APP_HOME=/app
12 |
13 | WORKDIR ${APP_HOME}
14 |
15 | RUN addgroup --system invoice \
16 | && adduser --system --ingroup invoice invoice
17 |
18 | RUN apk --update add ttf-freefont fontconfig && rm -rf /var/cache/apk/*
19 |
20 | RUN apk add --no-cache curl && \
21 | cd /tmp && curl -Ls https://github.com/dustinblackman/phantomized/releases/download/2.1.1/dockerized-phantomjs.tar.gz | tar xz && \
22 | cp -R lib lib64 / && \
23 | cp -R usr/lib/x86_64-linux-gnu /usr/lib && \
24 | cp -R usr/share /usr/share && \
25 | cp -R etc/fonts /etc && \
26 | curl -k -Ls https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar -jxf - &&\
27 | cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs && \
28 | rm -fR phantomjs-2.1.1-linux-x86_64 && \
29 | apk del curl
30 |
31 | COPY package*.json ./
32 |
33 | RUN npm install
34 |
35 | COPY --chown=invoice:invoice . ${APP_HOME}
36 |
37 | RUN chown invoice:invoice ${APP_HOME}
38 |
39 | USER invoice
40 |
41 | CMD [ "npm","run","dev" ]
--------------------------------------------------------------------------------
/docker/local/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:1.21.3-alpine
2 |
3 | RUN rm /etc/nginx/conf.d/default.conf
4 |
5 | COPY ./default.conf /etc/nginx/conf.d/default.conf
--------------------------------------------------------------------------------
/docker/local/nginx/default.conf:
--------------------------------------------------------------------------------
1 | upstream api {
2 | server api:1997;
3 | }
4 |
5 | upstream client {
6 | server client:3000;
7 | }
8 |
9 | server {
10 | client_max_body_size 20M;
11 |
12 | listen 80;
13 |
14 | location /api/v1/ {
15 | proxy_pass http://api;
16 |
17 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
18 |
19 | proxy_set_header Host $host;
20 |
21 | proxy_redirect off;
22 | }
23 |
24 | location /staticfiles/ {
25 | alias /app/staticfiles/;
26 | }
27 |
28 | location /ws {
29 | proxy_pass http://client;
30 |
31 | proxy_http_version 1.1;
32 |
33 | proxy_set_header Upgrade $http_upgrade;
34 | proxy_set_header Connection "Upgrade";
35 | }
36 |
37 | location / {
38 | proxy_pass http://client;
39 |
40 | proxy_redirect off;
41 |
42 | proxy_set_header Host $host;
43 |
44 | proxy_set_header X-Real-IP $remote_addr;
45 |
46 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
47 |
48 | proxy_set_header X-Forwarded-Host $server_name;
49 |
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/docker/production/express/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG NODE_VERSION=16-alpine3.12
2 |
3 | FROM node:${NODE_VERSION}
4 |
5 | LABEL mern.invoice.name="mern-invoice"
6 | LABEL mern.invoice.license="MIT"
7 |
8 | ENV NODE_ENV=production
9 |
10 | ARG APP_HOME=/app
11 |
12 | WORKDIR ${APP_HOME}
13 |
14 | RUN addgroup --system invoice \
15 | && adduser --system --ingroup invoice invoice
16 |
17 | RUN apk --update add ttf-freefont fontconfig && rm -rf /var/cache/apk/*
18 |
19 | RUN apk add --no-cache curl && \
20 | cd /tmp && curl -Ls https://github.com/dustinblackman/phantomized/releases/download/2.1.1/dockerized-phantomjs.tar.gz | tar xz && \
21 | cp -R lib lib64 / && \
22 | cp -R usr/lib/x86_64-linux-gnu /usr/lib && \
23 | cp -R usr/share /usr/share && \
24 | cp -R etc/fonts /etc && \
25 | curl -k -Ls https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 | tar -jxf - &&\
26 | cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin/phantomjs && \
27 | rm -fR phantomjs-2.1.1-linux-x86_64 && \
28 | apk del curl
29 |
30 | COPY package*.json ./
31 |
32 | RUN npm install
33 |
34 | COPY --chown=invoice:invoice . ${APP_HOME}
35 |
36 | RUN chown invoice:invoice ${APP_HOME}
37 |
38 | USER invoice
39 |
40 | CMD [ "npm","run","start" ]
--------------------------------------------------------------------------------
/docs/myDocument.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/API-Imperfect/mern-invoice/d8ff0d1167d342ef11db4f87077390f0a05df610/docs/myDocument.pdf
--------------------------------------------------------------------------------
/local.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | api:
5 | restart: always
6 | build:
7 | context: .
8 | dockerfile: ./docker/local/express/Dockerfile
9 | volumes:
10 | - /app/node_modules
11 | - .:/app
12 | depends_on:
13 | - mongodb
14 | # ports:
15 | # - "1997:1997"
16 | environment:
17 | NODE_ENV: ${NODE_ENV}
18 | MONGO_URI: mongodb://${MONGO_ROOT_USERNAME}:${MONGO_ROOT_PASSWORD}@mongodb
19 | FORCE_COLOR: 1
20 | networks:
21 | - invoice
22 |
23 | client:
24 | build:
25 | context: ./client
26 | dockerfile: ./docker/local/Dockerfile
27 | restart: on-failure
28 | volumes:
29 | - ./client:/app
30 | - /app/node_modules
31 | networks:
32 | - invoice
33 |
34 | mongodb:
35 | image: mongo:5.0.6-focal
36 | restart: always
37 | ports:
38 | - "27017:27017"
39 | environment:
40 | MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USERNAME}
41 | MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
42 | volumes:
43 | - mongodb-data:/data/db
44 | networks:
45 | - invoice
46 |
47 | mongo-express:
48 | image: mongo-express:0.54.0
49 | depends_on:
50 | - mongodb
51 | ports:
52 | - "8081:8081"
53 | environment:
54 | ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGO_ROOT_USERNAME}
55 | ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_ROOT_PASSWORD}
56 | ME_CONFIG_MONGODB_SERVER: mongodb
57 | ME_CONFIG_BASICAUTH_USERNAME: admin
58 | ME_CONFIG_BASICAUTH_PASSWORD: admin123456
59 | networks:
60 | - invoice
61 |
62 | mailhog:
63 | image: mailhog/mailhog:v1.0.0
64 | ports:
65 | - "8025:8025"
66 | - "1025:1025"
67 | networks:
68 | - invoice
69 |
70 | nginx:
71 | build:
72 | context: ./docker/local/nginx
73 | dockerfile: Dockerfile
74 | ports:
75 | - "8080:80"
76 | restart: always
77 | depends_on:
78 | - api
79 | volumes:
80 | - static_volume:/app/staticfiles
81 | - pdf_volume:/app/docs
82 | networks:
83 | - invoice
84 |
85 | networks:
86 | invoice:
87 | driver: bridge
88 |
89 | volumes:
90 | mongodb-data:
91 | static_volume:
92 | pdf_volume:
93 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mern-invoice",
3 | "version": "1.0.0",
4 | "description": "An invoicing web app built with the MERN Stack",
5 | "main": "server.js",
6 | "type": "module",
7 | "scripts": {
8 | "start": "node backend/server.js",
9 | "dev": "nodemon backend/server.js"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/API-Imperfect/mern-invoice.git"
14 | },
15 | "keywords": [
16 | "Invoice",
17 | "MERN",
18 | "ReduxToolkit"
19 | ],
20 | "author": "Alpha Ogilo",
21 | "license": "MIT",
22 | "bugs": {
23 | "url": "https://github.com/API-Imperfect/mern-invoice/issues"
24 | },
25 | "homepage": "https://github.com/API-Imperfect/mern-invoice#readme",
26 | "dependencies": {
27 | "bcryptjs": "^2.4.3",
28 | "chalk": "^5.1.2",
29 | "cloudinary": "^1.33.0",
30 | "cookie-parser": "^1.4.6",
31 | "dotenv": "^16.0.3",
32 | "express": "^4.18.2",
33 | "express-async-handler": "^1.2.0",
34 | "express-mongo-sanitize": "^2.2.0",
35 | "express-rate-limit": "^6.6.0",
36 | "handlebars": "^4.7.7",
37 | "html-pdf": "^3.0.1",
38 | "jsonwebtoken": "^8.5.1",
39 | "moment": "^2.29.4",
40 | "mongoose": "^6.6.6",
41 | "morgan": "^1.10.0",
42 | "multer": "^1.4.5-lts.1",
43 | "nodemailer": "^6.8.0",
44 | "nodemailer-mailgun-transport": "^2.1.5",
45 | "passport": "^0.6.0",
46 | "passport-google-oauth20": "^2.0.0",
47 | "validator": "^13.7.0",
48 | "winston": "^3.8.2",
49 | "winston-daily-rotate-file": "^4.7.1"
50 | },
51 | "devDependencies": {
52 | "nodemon": "^2.0.20"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/production.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | api:
5 | restart: unless-stopped
6 | build:
7 | context: .
8 | dockerfile: ./docker/production/express/Dockerfile
9 | environment:
10 | - NODE_ENV=production
11 | env_file:
12 | - ./.env
13 | ports:
14 | - "1997"
15 | networks:
16 | - reverseproxy_nw
17 |
18 | networks:
19 | reverseproxy_nw:
20 | external: true
21 |
--------------------------------------------------------------------------------