├── shared ├── constants │ └── constans.ts └── types │ └── types.ts ├── frontend ├── src │ ├── vite-env.d.ts │ ├── assets │ │ └── logo512.png │ ├── types │ │ └── types.ts │ ├── components │ │ ├── Loading.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── ExpenseItem.tsx │ │ ├── UpdateExpenseModal.tsx │ │ ├── ThemeSwitch.tsx │ │ ├── WeeklyChart.tsx │ │ └── Navbar.tsx │ ├── layouts │ │ └── MainLayout.tsx │ ├── pages │ │ ├── 404.tsx │ │ ├── Login.tsx │ │ ├── Home.tsx │ │ ├── Register.tsx │ │ ├── Add.tsx │ │ └── UserProfile.tsx │ ├── app │ │ └── store.ts │ ├── main.tsx │ ├── utils │ │ └── currencyFormatter.ts │ ├── styles │ │ └── index.css │ ├── hooks │ │ └── useSystemTheme.ts │ ├── features │ │ ├── settings │ │ │ └── settingsSlice.ts │ │ ├── expenses │ │ │ ├── expenseService.ts │ │ │ └── expenseSlice.ts │ │ └── auth │ │ │ ├── authService.ts │ │ │ └── authSlice.ts │ └── App.tsx ├── postcss.config.js ├── tsconfig.json ├── .gitignore ├── tsconfig.node.json ├── index.html ├── .eslintrc.cjs ├── tsconfig.app.json ├── package.json ├── vite.config.ts ├── README.md ├── tailwind.config.ts └── public │ └── logo.svg ├── .prettierignore ├── .prettierrc ├── screenshots ├── logo.png ├── console.png └── preview.png ├── .gitignore ├── .env.example ├── backend ├── config │ └── db.ts ├── tsconfig.json ├── middleware │ ├── errorMiddleware.ts │ ├── authMiddleware.ts │ └── upload.ts ├── routes │ ├── expenseRoutes.ts │ └── userRoutes.ts ├── types │ └── types.ts ├── models │ ├── user.ts │ └── expense.ts ├── server.ts └── controllers │ ├── expenseController.ts │ └── userController.ts ├── LICENSE ├── package.json └── README.md /shared/constants/constans.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | frontend/dist 3 | frontend/node_modules 4 | backend/dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "printWidth": 100, 4 | "tabWidth": 2 5 | } 6 | -------------------------------------------------------------------------------- /screenshots/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maciekt07/ExpenseTracker/HEAD/screenshots/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .env 4 | *.env 5 | *.env.local 6 | *.env.staging 7 | 8 | uploads/* 9 | -------------------------------------------------------------------------------- /screenshots/console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maciekt07/ExpenseTracker/HEAD/screenshots/console.png -------------------------------------------------------------------------------- /screenshots/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maciekt07/ExpenseTracker/HEAD/screenshots/preview.png -------------------------------------------------------------------------------- /frontend/src/assets/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maciekt07/ExpenseTracker/HEAD/frontend/src/assets/logo512.png -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/types/types.ts: -------------------------------------------------------------------------------- 1 | export interface Settings { 2 | theme: "system" | "light" | "dark"; 3 | currency: `${string}${string}${string}`; 4 | } 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=8000 3 | MONGO_URI="Your MongoDB URI (mongodb+srv://[username:password@]host[/[defaultauthdb][?options]])" 4 | JWT_SECRET="Your JWT secret" -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | function Loading() { 2 | return ( 3 |
4 | 5 |
6 | ); 7 | } 8 | 9 | export default Loading; 10 | -------------------------------------------------------------------------------- /frontend/src/layouts/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "../components/Navbar"; 2 | 3 | function MainLayout({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
6 | 7 | {children} 8 |
9 | ); 10 | } 11 | 12 | export default MainLayout; 13 | -------------------------------------------------------------------------------- /frontend/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | function NotFound() { 2 | return ( 3 |
4 |
5 |

404

6 |

Page not found

7 |
8 |
9 | ); 10 | } 11 | 12 | export default NotFound; 13 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | dev-dist 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /backend/config/db.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import "dotenv/config"; 3 | 4 | const connectDB = async () => { 5 | try { 6 | await mongoose.connect((process.env.MONGO_URI as string) || ""); 7 | console.log(`🟢 Connected to MongoDB!`); 8 | } catch (error) { 9 | console.log("Error connecting to MongoDB:", error); 10 | process.exit(1); 11 | } 12 | }; 13 | export default connectDB; 14 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2016", 5 | "outDir": "./dist", 6 | "esModuleInterop": true, 7 | "composite": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "baseUrl": ".", 12 | "rootDir": "../", 13 | "paths": { 14 | "@shared/*": ["../shared/*"] 15 | } 16 | }, 17 | "include": ["./**/*", "../shared/**/*"], 18 | "exclude": ["./dist"] 19 | } 20 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | Expense Tracker 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /backend/middleware/errorMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from "express"; 2 | 3 | interface ErrorResponse { 4 | message: string; 5 | stack?: string; 6 | } 7 | 8 | export const errorHandler = ( 9 | err: ErrorResponse, 10 | req: Request, 11 | res: Response, 12 | next: NextFunction, 13 | ) => { 14 | const statusCode = res.statusCode || 500; 15 | res.status(statusCode).json({ 16 | message: err.message, 17 | stack: process.env.NODE_ENV === "production" ? undefined : err.stack, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /backend/routes/expenseRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | createExpense, 4 | deleteExpense, 5 | getAllExpenses, 6 | updateExpense, 7 | } from "../controllers/expenseController"; 8 | import protect from "../middleware/authMiddleware"; 9 | 10 | const router = Router(); 11 | 12 | router.post("/", protect, createExpense); 13 | 14 | router.get("/", protect, getAllExpenses); 15 | 16 | router.put("/:id", protect, updateExpense); 17 | 18 | router.delete("/:id", protect, deleteExpense); 19 | 20 | export { router as ExpenseRouter }; 21 | -------------------------------------------------------------------------------- /backend/types/types.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from "express"; 2 | 3 | export interface User { 4 | name: string; 5 | email: string; 6 | password: string; 7 | profilePicture?: string; 8 | } 9 | 10 | export interface Expense { 11 | user: User; 12 | text: string; 13 | customDate?: string; 14 | type: "income" | "expense"; 15 | amount: number; 16 | } 17 | 18 | export interface UserData { 19 | id: string; 20 | name: string; 21 | email: string; 22 | } 23 | export interface AuthenticatedRequest extends Request { 24 | user: UserData; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/app/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import authReducer from "../features/auth/authSlice"; 3 | import expenseReducer from "../features/expenses/expenseSlice"; 4 | import settingsReducer from "../features/settings/settingsSlice"; 5 | 6 | export const store = configureStore({ 7 | reducer: { 8 | auth: authReducer, 9 | expenses: expenseReducer, 10 | settings: settingsReducer, 11 | }, 12 | }); 13 | export type AppDispatch = typeof store.dispatch; 14 | export type RootState = ReturnType; 15 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import App from "./App.tsx"; 3 | import "./styles/index.css"; 4 | import { BrowserRouter } from "react-router-dom"; 5 | import { Provider } from "react-redux"; 6 | import { store } from "./app/store.ts"; 7 | import { IconContext } from "react-icons"; 8 | ReactDOM.createRoot(document.getElementById("root")!).render( 9 | 10 | 11 | 12 | 13 | {" "} 14 | 15 | , 16 | ); 17 | -------------------------------------------------------------------------------- /frontend/src/utils/currencyFormatter.ts: -------------------------------------------------------------------------------- 1 | import { store } from "../app/store"; 2 | import { RootState } from "../app/store"; 3 | import { Settings } from "../types/types"; 4 | 5 | // Function to get settings from the store 6 | function getSettings(): Settings { 7 | const state = store.getState() as RootState; 8 | return state.settings.settings; 9 | } 10 | 11 | // Function to format currency based on settings 12 | export function formatCurrency(amount: number): string { 13 | const settings = getSettings(); 14 | const formatter = new Intl.NumberFormat(navigator.language || "en-US", { 15 | style: "currency", 16 | currency: settings.currency || "USD", 17 | }); 18 | return formatter.format(amount); 19 | } 20 | -------------------------------------------------------------------------------- /backend/models/user.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import { User } from "@shared/types/types"; 3 | 4 | const userSchema = new mongoose.Schema( 5 | { 6 | name: { 7 | type: String, 8 | required: [true, "Username is required"], 9 | default: "User", 10 | }, 11 | profilePicture: { 12 | type: String, 13 | default: undefined, 14 | }, 15 | email: { 16 | type: String, 17 | required: [true, "Email is required"], 18 | unique: true, 19 | }, 20 | password: { 21 | type: String, 22 | required: [true, "Password is required"], 23 | minlength: [4, "Password must be at least 4 characters"], 24 | }, 25 | }, 26 | { timestamps: true }, 27 | ); 28 | const User = mongoose.model("User", userSchema); 29 | export default User; 30 | -------------------------------------------------------------------------------- /backend/routes/userRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import { 3 | getUserData, 4 | loginUser, 5 | registerUser, 6 | removeProfilePicture, 7 | updateUser, 8 | uploadProfilePicture, 9 | } from "../controllers/userController"; 10 | import protect from "../middleware/authMiddleware"; 11 | import upload from "../middleware/upload"; 12 | 13 | const router = Router(); 14 | 15 | router.post("/", registerUser); 16 | router.post("/login", loginUser); 17 | router.get("/me", protect, getUserData); 18 | router.put("/update", protect, updateUser); 19 | router.post( 20 | "/upload-profile-picture", 21 | protect, 22 | upload.single("profilePicture"), 23 | uploadProfilePicture, 24 | ); 25 | 26 | router.post("/remove-profile-picture", protect, removeProfilePicture); 27 | 28 | export { router as UserRouter }; 29 | -------------------------------------------------------------------------------- /frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "baseUrl": ".", 22 | "paths": { 23 | "@shared/*": ["../shared/*"] 24 | } 25 | }, 26 | "include": ["src", "../shared/**/*"] 27 | } 28 | -------------------------------------------------------------------------------- /backend/models/expense.ts: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import type { Expense } from "@shared/types/types"; 3 | 4 | const expenseSchema = new mongoose.Schema( 5 | { 6 | user: { 7 | type: mongoose.Schema.Types.ObjectId, 8 | required: true, 9 | ref: "User", 10 | }, 11 | text: { 12 | type: String, 13 | required: [true, "Expense text is required"], 14 | }, 15 | customDate: { 16 | type: String, 17 | }, 18 | type: { 19 | type: String, 20 | required: [true, "Expense type is required"], 21 | enum: ["income", "expense"], 22 | default: "expense", 23 | }, 24 | amount: { 25 | type: Number, 26 | required: [true, "Expense amount is required"], 27 | default: 0, 28 | }, 29 | }, 30 | { timestamps: true }, 31 | ); 32 | 33 | const Expense = mongoose.model("Expense", expenseSchema); 34 | export default Expense; 35 | -------------------------------------------------------------------------------- /frontend/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600;700&display=swap"); 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | :root { 7 | font-family: "Poppins", sans-serif; 8 | line-height: 1.5; 9 | font-weight: 400; 10 | color-scheme: light dark; 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | } 16 | 17 | [data-theme="lightTheme"] { 18 | color-scheme: light; 19 | } 20 | 21 | [data-theme="darkTheme"] { 22 | color-scheme: dark; 23 | } 24 | 25 | * ::selection { 26 | @apply bg-primary; 27 | @apply text-primary-content; 28 | } 29 | 30 | .tooltip::before { 31 | @apply p-2 rounded-md; 32 | } 33 | .tooltip::after { 34 | @apply hidden; 35 | } 36 | 37 | .sidebar-btn { 38 | @apply block p-2 text-lg hover:bg-base-300 rounded w-full text-left; 39 | } 40 | -------------------------------------------------------------------------------- /shared/types/types.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | name: string; 3 | email: string; 4 | password: string; 5 | profilePicture?: string; 6 | } 7 | 8 | export interface Expense { 9 | user: User; 10 | text: string; 11 | customDate?: string; 12 | type: "income" | "expense"; 13 | amount: number; 14 | } 15 | 16 | export interface MongoDocument { 17 | _id: string; 18 | createdAt: string; 19 | updatedAt: string; 20 | } 21 | 22 | export interface ExpenseDocument extends MongoDocument, Expense {} 23 | export interface UserDocument extends MongoDocument, User {} 24 | export interface UserDataDocument extends MongoDocument, UserData {} 25 | 26 | export interface UserData { 27 | name: string; 28 | email: string; 29 | password: string; 30 | profilePicture?: string; 31 | token?: string; 32 | } 33 | 34 | export interface AuthResponse { 35 | _id: string; 36 | name: string; 37 | email: string; 38 | profilePicture?: string; 39 | token: string; 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/hooks/useSystemTheme.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | type Theme = "light" | "dark" | "unknown"; 4 | 5 | /** 6 | * A React hook to detect the system theme preference. 7 | * @returns The current system theme ('light', 'dark', or 'unknown'). 8 | */ 9 | export const useSystemTheme = (): Theme => { 10 | const [theme, setTheme] = useState("unknown"); 11 | useEffect(() => { 12 | const mediaQueryListener = (e: MediaQueryListEvent) => { 13 | setTheme(e.matches ? "dark" : "light"); 14 | }; 15 | 16 | const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)"); 17 | setTheme(prefersDarkScheme.matches ? "dark" : "light"); 18 | 19 | // Listen for changes in system theme 20 | prefersDarkScheme.addEventListener("change", mediaQueryListener); 21 | 22 | return () => { 23 | prefersDarkScheme.removeEventListener("change", mediaQueryListener); 24 | }; 25 | }, []); 26 | 27 | return theme; 28 | }; 29 | -------------------------------------------------------------------------------- /backend/server.ts: -------------------------------------------------------------------------------- 1 | import cors from "cors"; 2 | import "dotenv/config"; 3 | import express from "express"; 4 | import path from "path"; 5 | import connectDB from "./config/db"; 6 | import { errorHandler } from "./middleware/errorMiddleware"; 7 | import { ExpenseRouter } from "./routes/expenseRoutes"; 8 | import { UserRouter } from "./routes/userRoutes"; 9 | 10 | const app = express(); 11 | const port = process.env.PORT || 8000; 12 | 13 | // Connect to the database 14 | connectDB(); 15 | 16 | // Middleware 17 | app.use(cors()); 18 | app.use(express.json()); 19 | app.use(express.urlencoded({ extended: true })); 20 | 21 | // Static file serving 22 | app.use("/uploads", express.static(path.join("uploads"))); 23 | 24 | // Routes 25 | app.use("/api/expenses", ExpenseRouter); 26 | app.use("/api/users", UserRouter); 27 | 28 | // Error handling middleware 29 | app.use(errorHandler); 30 | 31 | // Default route 32 | app.get("/", (req, res) => { 33 | res.send("hello world"); 34 | }); 35 | 36 | // Start server 37 | app.listen(port, () => { 38 | console.log(`🌐 Server running on http://localhost:${port}`); 39 | }); 40 | -------------------------------------------------------------------------------- /frontend/src/features/settings/settingsSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { Settings } from "../../types/types"; 3 | 4 | const loadSettingsFromLocalStorage = (): Settings => { 5 | const savedSettings = localStorage.getItem("settings"); 6 | return savedSettings ? JSON.parse(savedSettings) : { theme: "system", currency: "USD" }; 7 | }; 8 | 9 | const defaultSettings: Settings = loadSettingsFromLocalStorage(); 10 | 11 | export const initialState = { 12 | settings: defaultSettings, 13 | }; 14 | 15 | export const settingsSlice = createSlice({ 16 | name: "settings", 17 | initialState, 18 | reducers: { 19 | reset: (state) => { 20 | localStorage.removeItem("settings"); 21 | state.settings = defaultSettings; 22 | }, 23 | updateSettings: (state, action: PayloadAction) => { 24 | state.settings = action.payload; 25 | localStorage.setItem("settings", JSON.stringify(action.payload)); 26 | }, 27 | }, 28 | }); 29 | 30 | export const { reset, updateSettings } = settingsSlice.actions; 31 | export default settingsSlice.reducer; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Maciej Twarog (github.com/maciekt07) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/middleware/authMiddleware.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import asyncHandler from "express-async-handler"; 3 | import User from "../models/user"; 4 | import type { Request } from "express"; 5 | 6 | interface DecodedToken { 7 | id: string; 8 | iat: number; 9 | exp: number; 10 | } 11 | 12 | interface CustomRequest extends Request { 13 | user?: any; 14 | } 15 | 16 | const protect = asyncHandler(async (req: CustomRequest, res, next) => { 17 | let token; 18 | 19 | if (req.headers.authorization && req.headers.authorization.startsWith("Bearer")) { 20 | try { 21 | // get token from header 22 | token = req.headers.authorization.split(" ")[1]; 23 | // verify token 24 | const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as DecodedToken; 25 | // get user from token 26 | req.user = await User.findById(decoded.id).select("-password"); 27 | next(); 28 | } catch (error) { 29 | console.log(error); 30 | res.status(401); 31 | throw new Error("Unauthorized"); 32 | } 33 | } 34 | if (!token) { 35 | res.status(401); 36 | throw new Error("Unauthorized, no token"); 37 | } 38 | }); 39 | 40 | export default protect; 41 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "devhost": "vite --host", 9 | "build": "tsc -b && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@reduxjs/toolkit": "^2.2.6", 15 | "axios": "^1.7.2", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.3.1", 18 | "react-hot-toast": "^2.4.1", 19 | "react-icons": "^5.2.1", 20 | "react-redux": "^9.1.2", 21 | "react-router-dom": "^6.25.1" 22 | }, 23 | "devDependencies": { 24 | "@types/react": "^18.3.3", 25 | "@types/react-dom": "^18.3.0", 26 | "@typescript-eslint/eslint-plugin": "^7.15.0", 27 | "@typescript-eslint/parser": "^7.15.0", 28 | "@vitejs/plugin-react": "^4.3.1", 29 | "autoprefixer": "^10.4.19", 30 | "daisyui": "^4.12.10", 31 | "eslint": "^8.57.0", 32 | "eslint-plugin-react-hooks": "^4.6.2", 33 | "eslint-plugin-react-refresh": "^0.4.7", 34 | "postcss": "^8.4.39", 35 | "tailwindcss": "^3.4.6", 36 | "typescript": "^5.2.2", 37 | "vite": "^5.3.4", 38 | "vite-plugin-pwa": "^0.20.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { VitePWA } from "vite-plugin-pwa"; 4 | import path from "path"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | VitePWA({ 11 | devOptions: { 12 | enabled: true, 13 | type: "module", 14 | }, 15 | registerType: "autoUpdate", 16 | includeAssets: ["**/*"], 17 | manifest: { 18 | name: "Expense Tracker", 19 | short_name: "Expense Tracker", 20 | theme_color: "#0061FF", 21 | //TODO: Add icons 22 | icons: [ 23 | { 24 | src: "/logo.svg", 25 | sizes: "512x512", 26 | type: "image/svg+xml", 27 | }, 28 | { 29 | src: "/logo.png", 30 | sizes: "512x512", 31 | type: "image/png", 32 | }, 33 | ], 34 | }, 35 | }), 36 | ], 37 | resolve: { 38 | alias: { 39 | "@shared": path.resolve(__dirname, "../shared"), // Add alias for shared folder 40 | }, 41 | }, 42 | server: { 43 | proxy: { 44 | "/api": "http://localhost:8000", 45 | "/uploads": { 46 | target: "http://localhost:8000", 47 | changeOrigin: true, 48 | rewrite: (path) => path.replace(/^\/uploads/, "/uploads"), 49 | }, 50 | }, 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json', './tsconfig.app.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expense-tracker", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "backend/server.ts", 6 | "scripts": { 7 | "dev": "concurrently --names \"SERVER,CLIENT\" --prefix \"[{name}]\" --prefix-colors \"yellow,blue\" \"npm run server\" \"npm run client\"", 8 | "format": "prettier --write \"frontend/**/*.{ts,tsx,css,md,json}\" && prettier --write \"backend/**/*.{ts,tsx,json}\"", 9 | "client": "npm run dev --prefix frontend", 10 | "server": "nodemon backend/server.ts", 11 | "build": "tsc --build backend", 12 | "start": "node /backend/dist/server.js" 13 | }, 14 | "keywords": [], 15 | "author": "github.com/maciekt07", 16 | "license": "MIT", 17 | "paths": { 18 | "@shared/*": [ 19 | "shared/*" 20 | ] 21 | }, 22 | "devDependencies": { 23 | "@types/bcrypt": "^5.0.2", 24 | "@types/bcryptjs": "^2.4.6", 25 | "@types/cors": "^2.8.17", 26 | "@types/express": "^4.17.21", 27 | "@types/jsonwebtoken": "^9.0.6", 28 | "@types/mongoose": "^5.11.97", 29 | "@types/multer": "^1.4.11", 30 | "@types/node": "^20.14.11", 31 | "concurrently": "^8.2.2", 32 | "nodemon": "^3.1.4", 33 | "prettier": "^3.3.3", 34 | "ts-node": "^10.9.2", 35 | "typescript": "^5.5.3" 36 | }, 37 | "dependencies": { 38 | "bcrypt": "^5.1.1", 39 | "cors": "^2.8.5", 40 | "dotenv": "^16.4.5", 41 | "express": "^4.19.2", 42 | "express-async-handler": "^1.2.0", 43 | "js": "^0.1.0", 44 | "jsonwebtoken": "^9.0.2", 45 | "mongoose": "^8.5.1", 46 | "multer": "^1.4.5-lts.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { ErrorInfo } from "react"; 2 | 3 | interface ErrorBoundaryProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | interface ErrorBoundaryState { 8 | hasError: boolean; 9 | error?: Error; 10 | } 11 | 12 | /** 13 | * ErrorBoundary component that catches and displays errors. 14 | */ 15 | 16 | class ErrorBoundary extends React.Component { 17 | constructor(props: ErrorBoundaryProps) { 18 | super(props); 19 | this.state = { 20 | hasError: false, 21 | }; 22 | } 23 | 24 | static getDerivedStateFromError(error: Error): ErrorBoundaryState { 25 | return { 26 | hasError: true, 27 | error: error, 28 | }; 29 | } 30 | 31 | componentDidCatch(error: Error, errorInfo: ErrorInfo): void { 32 | console.error("Error:", error); 33 | console.error("Error Info:", errorInfo); 34 | } 35 | 36 | handleClearData() { 37 | localStorage.clear(); 38 | sessionStorage.clear(); 39 | location.reload(); 40 | } 41 | 42 | render() { 43 | if (this.state.hasError) { 44 | return ( 45 |
46 |

Oops! An error occurred.

47 |

48 | To fix it, try clearing your local files (cookies and cache) and then refresh the page. 49 |

50 |
{this.state.error?.message}
51 | 52 | 53 |
54 | ); 55 | } 56 | 57 | return this.props.children; 58 | } 59 | } 60 | 61 | export default ErrorBoundary; 62 | -------------------------------------------------------------------------------- /frontend/src/components/ExpenseItem.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import { AppDispatch } from "../app/store"; 4 | import { deleteExpense } from "../features/expenses/expenseSlice"; 5 | import { ExpenseDocument } from "@shared/types/types"; 6 | import { FaTrashCan } from "react-icons/fa6"; 7 | import { formatCurrency } from "../utils/currencyFormatter"; 8 | import UpdateExpenseModal from "./UpdateExpenseModal"; 9 | 10 | function ExpenseItem({ expense }: { expense: ExpenseDocument }) { 11 | const dispatch = useDispatch(); 12 | const [isModalOpen, setIsModalOpen] = useState(false); 13 | 14 | const handleDelete = () => { 15 | dispatch(deleteExpense(expense._id)); 16 | }; 17 | 18 | return ( 19 |
20 |

{expense.text}

21 |

22 | {expense.type === "expense" ? "-" : "+"} 23 | {formatCurrency(expense.amount)} 24 |

25 |
26 | 29 | 32 |
33 | setIsModalOpen(false)} 37 | /> 38 |
39 | ); 40 | } 41 | 42 | export default ExpenseItem; 43 | -------------------------------------------------------------------------------- /frontend/src/features/expenses/expenseService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Expense } from "@shared/types/types"; 3 | 4 | const API_URL = "api/expenses/"; 5 | 6 | // Reusable config function 7 | const createConfig = (token: string) => ({ 8 | headers: { 9 | Authorization: `Bearer ${token}`, 10 | }, 11 | }); 12 | 13 | // Create new expense 14 | const createExpense = async (expenseData: Expense, token: string) => { 15 | const response = await axios.post(API_URL, expenseData, createConfig(token)); 16 | return response.data; 17 | }; 18 | 19 | // get user expenses 20 | const getExpenses = async (token: string) => { 21 | const response = await axios.get(API_URL, createConfig(token)); 22 | return response.data; 23 | }; 24 | 25 | const getExpenseDetails = async (expenseId: string, token: string) => { 26 | try { 27 | const response = await axios.get(`${API_URL}${expenseId}`, createConfig(token)); 28 | return response.data; 29 | } catch (error) { 30 | console.error("Error fetching expense details:", error); 31 | throw error; 32 | } 33 | }; 34 | 35 | // delete expense 36 | const deleteExpense = async (expenseId: string, token: string) => { 37 | const response = await axios.delete(API_URL + expenseId, createConfig(token)); 38 | return response.data; 39 | }; 40 | 41 | // Add this function 42 | const updateExpense = async (expenseData: Expense, token: string) => { 43 | const response = await axios.put( 44 | `${API_URL}${expenseData._id}`, 45 | expenseData, 46 | createConfig(token), 47 | ); 48 | return response.data; 49 | }; 50 | 51 | const expenseService = { 52 | createExpense, 53 | getExpenses, 54 | getExpenseDetails, 55 | deleteExpense, 56 | updateExpense, 57 | }; 58 | export default expenseService; 59 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import daisyui from "daisyui"; 2 | import type { Config } from "tailwindcss"; 3 | 4 | export default { 5 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 6 | plugins: [daisyui], 7 | daisyui: { 8 | themes: [ 9 | { 10 | lightTheme: { 11 | primary: "#0061FF", 12 | "primary-content": "#ffffff", 13 | secondary: "#498EFF", 14 | "secondary-content": "#ffffff", 15 | accent: "#00cdb7", 16 | "accent-content": "#ffffff", 17 | neutral: "#2a323c", 18 | "neutral-content": "#ffffff", 19 | "base-100": "#ffffff", 20 | "base-200": "#f0f0f0", 21 | "base-300": "#e0e0e0", 22 | "base-content": "#1d232a", 23 | info: "#1e90ff", 24 | "info-content": "#ffffff", 25 | success: "#28a745", 26 | "success-content": "#ffffff", 27 | warning: "#ffc107", 28 | "warning-content": "#ffffff", 29 | error: "#dc3545", 30 | "error-content": "#ffffff", 31 | }, 32 | }, 33 | { 34 | darkTheme: { 35 | primary: "#0061FF", 36 | "primary-content": "#ffffff", 37 | secondary: "#498EFF", 38 | "secondary-content": "#ffffff", 39 | accent: "#00cdb7", 40 | "accent-content": "#ffffff", 41 | neutral: "#2a323c", 42 | "neutral-content": "#ffffff", 43 | "base-100": "#1d232a", 44 | "base-200": "#2a323c", 45 | "base-300": "#3a3f45", 46 | "base-content": "#e0e0e0", 47 | info: "#1e90ff", 48 | "info-content": "#ffffff", 49 | success: "#28a745", 50 | "success-content": "#ffffff", 51 | warning: "#ffc107", 52 | "warning-content": "#ffffff", 53 | error: "#dc3545", 54 | "error-content": "#ffffff", 55 | }, 56 | }, 57 | ], 58 | darkTheme: "darkTheme", 59 | base: true, 60 | styled: true, 61 | utils: true, 62 | prefix: "", 63 | themeRoot: ":root", 64 | }, 65 | } satisfies Config; 66 | -------------------------------------------------------------------------------- /backend/middleware/upload.ts: -------------------------------------------------------------------------------- 1 | import multer, { FileFilterCallback } from "multer"; 2 | import path from "path"; 3 | import fs from "fs/promises"; 4 | import { Request } from "express"; 5 | import { AuthenticatedRequest } from "../types/types"; 6 | 7 | // Create uploads directory if it doesn't exist 8 | const uploadDir = "uploads"; 9 | 10 | const createUploadsDir = async (): Promise => { 11 | try { 12 | await fs.mkdir(uploadDir, { recursive: true }); 13 | } catch (err) { 14 | console.error("Failed to create directory:", err); 15 | } 16 | }; 17 | 18 | createUploadsDir(); 19 | 20 | // Helper function to delete all files with the same user ID 21 | const deleteOldFiles = async (userId: string): Promise => { 22 | try { 23 | const files = await fs.readdir(uploadDir); 24 | await Promise.all( 25 | files 26 | .filter((file) => file.startsWith(userId)) 27 | .map((file) => fs.unlink(path.join(uploadDir, file))), 28 | ); 29 | console.log(`Deleted old files for user ID: ${userId}`); 30 | } catch (err) { 31 | console.error("Failed to delete files:", err); 32 | } 33 | }; 34 | 35 | // File filter function to allow only image formats 36 | const imageFileFilter = (req: Request, file: Express.Multer.File, cb: FileFilterCallback): void => { 37 | const allowedMimeTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; 38 | if (allowedMimeTypes.includes(file.mimetype)) { 39 | cb(null, true); 40 | } else { 41 | cb(new Error("Invalid file type. Only image files are allowed.")); 42 | } 43 | }; 44 | 45 | const storage = multer.diskStorage({ 46 | destination: (req, file, cb) => { 47 | cb(null, uploadDir); 48 | }, 49 | filename: async (req, file, cb) => { 50 | const AuthReq = req as AuthenticatedRequest; 51 | const userId = AuthReq.user ? AuthReq.user.id : Date.now().toString(); // Fallback to timestamp if user ID is not available 52 | 53 | try { 54 | await deleteOldFiles(userId); 55 | cb(null, `${userId}${path.extname(file.originalname)}`); 56 | } catch (err) { 57 | cb(err as Error, ""); // Pass empty string for filename if there's an error 58 | } 59 | }, 60 | }); 61 | 62 | const upload = multer({ 63 | storage, 64 | fileFilter: imageFileFilter, // Apply the file filter 65 | }); 66 | 67 | export default upload; 68 | -------------------------------------------------------------------------------- /frontend/src/components/UpdateExpenseModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Expense, ExpenseDocument } from "@shared/types/types"; 3 | import { useDispatch } from "react-redux"; 4 | import { updateExpense } from "../features/expenses/expenseSlice"; 5 | import toast from "react-hot-toast"; 6 | import { AppDispatch } from "src/app/store"; 7 | 8 | interface UpdateExpenseModalProps { 9 | expense: ExpenseDocument; 10 | isOpen: boolean; 11 | onClose: () => void; 12 | } 13 | 14 | const UpdateExpenseModal: React.FC = ({ expense, isOpen, onClose }) => { 15 | const [text, setText] = useState(expense.text); 16 | const [amount, setAmount] = useState(expense.amount); 17 | const [type, setType] = useState(expense.type); 18 | const dispatch = useDispatch(); 19 | 20 | const handleUpdate = async () => { 21 | try { 22 | await dispatch(updateExpense({ ...expense, text, amount, type })); 23 | toast.success("Expense updated successfully!"); 24 | onClose(); 25 | } catch (error) { 26 | toast.error("Failed to update expense."); 27 | } 28 | }; 29 | 30 | return ( 31 | <> 32 | {isOpen && ( 33 |
34 |
35 |

Update Expense {expense.text}

36 | setText(e.target.value)} 40 | className="input input-bordered w-full mt-2" 41 | placeholder="Expense Name" 42 | /> 43 | setAmount(Number(e.target.value))} 47 | className="input input-bordered w-full mt-2" 48 | placeholder="Amount" 49 | /> 50 | 58 |
59 | 62 | 65 |
66 |
67 |
68 | )} 69 | 70 | ); 71 | }; 72 | 73 | export default UpdateExpenseModal; 74 | -------------------------------------------------------------------------------- /frontend/src/components/ThemeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { AppDispatch, RootState } from "../app/store"; 4 | import { updateSettings } from "../features/settings/settingsSlice"; 5 | import { Settings } from "../types/types"; 6 | import { FaMoon, FaSun, FaDesktop } from "react-icons/fa"; 7 | import { useSystemTheme } from "../hooks/useSystemTheme"; 8 | 9 | type ThemeKey = Settings["theme"]; 10 | 11 | const themes: Record = { 12 | light: { 13 | icon: , 14 | label: "Light", 15 | }, 16 | dark: { 17 | icon: , 18 | label: "Dark", 19 | }, 20 | system: { 21 | icon: , 22 | label: "System", 23 | }, 24 | }; 25 | 26 | const ThemeSwitcher: React.FC = () => { 27 | const dispatch = useDispatch(); 28 | const settings = useSelector((state: RootState) => state.settings.settings); 29 | const [isOpen, setIsOpen] = useState(false); 30 | const systemTheme = useSystemTheme(); 31 | const dropdownRef = useRef(null); 32 | 33 | const handleThemeChange = (newTheme: ThemeKey) => { 34 | dispatch(updateSettings({ theme: newTheme, currency: settings.currency })); 35 | setIsOpen(false); // Close menu after selecting theme 36 | }; 37 | 38 | const handleClickOutside = (event: MouseEvent) => { 39 | if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { 40 | setIsOpen(false); 41 | } 42 | }; 43 | 44 | useEffect(() => { 45 | document.addEventListener("mousedown", handleClickOutside); 46 | return () => { 47 | document.removeEventListener("mousedown", handleClickOutside); 48 | }; 49 | }, []); 50 | 51 | const currentTheme = settings.theme; 52 | const themeIcon = 53 | currentTheme === "system" 54 | ? systemTheme === "dark" 55 | ? themes.dark.icon 56 | : themes.light.icon 57 | : themes[currentTheme]?.icon; 58 | 59 | return ( 60 |
61 | 64 | {isOpen && ( 65 |
    66 | {Object.keys(themes).map((key) => { 67 | const themeKey = key as ThemeKey; 68 | return ( 69 |
  • 70 | 76 |
  • 77 | ); 78 | })} 79 |
80 | )} 81 |
82 | ); 83 | }; 84 | 85 | export default ThemeSwitcher; 86 | -------------------------------------------------------------------------------- /backend/controllers/expenseController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import asyncHandler from "express-async-handler"; 3 | import Expense from "../models/expense"; 4 | import User from "../models/user"; 5 | import { AuthenticatedRequest } from "../types/types"; 6 | 7 | export const getAllExpenses = asyncHandler(async (req: Request, res: Response) => { 8 | const expenses = await Expense.find({ 9 | user: (req as AuthenticatedRequest).user.id, 10 | }); 11 | res.status(200).json(expenses); 12 | }); 13 | 14 | export const createExpense = asyncHandler(async (req: Request, res: Response) => { 15 | if (!req.body.text) { 16 | res.status(400); 17 | throw new Error("Missing text"); 18 | } 19 | if (!req.body.amount) { 20 | res.status(400); 21 | throw new Error("Missing amount"); 22 | } 23 | 24 | if (req.body.amount < 0) { 25 | res.status(400); 26 | throw new Error("Amount cannot be negative"); 27 | } 28 | 29 | if (req.body.type !== "income" && req.body.type !== "expense") { 30 | res.status(400); 31 | throw new Error("Invalid type"); 32 | } 33 | 34 | const expense = Expense.create({ 35 | text: req.body.text, 36 | amount: req.body.amount, 37 | type: req.body.type, 38 | user: (req as AuthenticatedRequest).user.id, 39 | customDate: req.body.customDate, 40 | }); 41 | 42 | res.status(200).json(expense); 43 | }); 44 | 45 | export const updateExpense = asyncHandler(async (req: Request, res: Response) => { 46 | const expense = await Expense.findById(req.params.id); 47 | if (!expense) { 48 | res.status(400); 49 | throw new Error("Expense not found"); 50 | } 51 | if (req.body.amount < 0) { 52 | res.status(400); 53 | throw new Error("Amount cannot be negative"); 54 | } 55 | 56 | const user = await User.findById((req as AuthenticatedRequest).user.id); 57 | if (!user) { 58 | res.status(401); 59 | throw new Error("User not found"); 60 | } 61 | 62 | if (expense.user.toString() !== user.id) { 63 | res.status(401); 64 | throw new Error("Not authorized"); 65 | } 66 | 67 | if (req.body.type !== "income" && req.body.type !== "expense") { 68 | res.status(400); 69 | throw new Error("Invalid type"); 70 | } 71 | 72 | const updatedExpense = await Expense.findByIdAndUpdate(req.params.id, req.body, { new: true }); 73 | res.status(200).json(updatedExpense); 74 | }); 75 | 76 | export const deleteExpense = asyncHandler(async (req: Request, res: Response) => { 77 | const expense = await Expense.findById(req.params.id); 78 | if (!expense) { 79 | res.status(400); 80 | throw new Error("Expense not found"); 81 | } 82 | const user = await User.findById((req as AuthenticatedRequest).user.id); 83 | if (!user) { 84 | res.status(401); 85 | throw new Error("User not found"); 86 | } 87 | 88 | if (expense.user.toString() !== user.id) { 89 | res.status(401); 90 | throw new Error("Not authorized"); 91 | } 92 | await Expense.findByIdAndDelete(req.params.id); 93 | res.status(200).json(expense); 94 | }); 95 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from "react-router-dom"; 2 | import Home from "./pages/Home"; 3 | import NotFound from "./pages/404"; 4 | import Login from "./pages/Login"; 5 | import Register from "./pages/Register"; 6 | import MainLayout from "./layouts/MainLayout"; 7 | import { useSelector } from "react-redux"; 8 | import { RootState } from "./app/store"; 9 | import Add from "./pages/Add"; 10 | import { Toaster } from "react-hot-toast"; 11 | import UserProfile from "./pages/UserProfile"; 12 | import { useEffect } from "react"; 13 | import { useSystemTheme } from "./hooks/useSystemTheme"; 14 | 15 | function App() { 16 | const theme = useSelector((state: RootState) => state.settings.settings.theme); 17 | 18 | const systemTheme = useSystemTheme(); 19 | 20 | useEffect(() => { 21 | const htmlElement = document.documentElement; 22 | const metaThemeColor = document.querySelector("meta[name=theme-color]") as HTMLMetaElement; 23 | if (theme === "system") { 24 | htmlElement.removeAttribute("data-theme"); 25 | metaThemeColor.setAttribute("content", systemTheme === "dark" ? "#1d232a" : "#ffffff"); 26 | } else if (theme === "light") { 27 | htmlElement.setAttribute("data-theme", "lightTheme"); 28 | metaThemeColor.setAttribute("content", "#ffffff"); 29 | } else if (theme === "dark") { 30 | htmlElement.setAttribute("data-theme", "darkTheme"); 31 | metaThemeColor.setAttribute("content", "#1d232a"); 32 | } 33 | }, [theme, systemTheme]); 34 | 35 | return ( 36 | 37 | 74 | 75 | } /> 76 | } /> 77 | } /> 78 | } /> 79 | } /> 80 | } /> 81 | 82 | 83 | ); 84 | } 85 | 86 | export default App; 87 | -------------------------------------------------------------------------------- /frontend/src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { Link, useNavigate } from "react-router-dom"; 4 | import { AppDispatch, RootState } from "../app/store"; 5 | import { login, reset } from "../features/auth/authSlice"; 6 | 7 | import toast from "react-hot-toast"; 8 | import Loading from "../components/Loading"; 9 | import { UserData } from "@shared/types/types"; 10 | 11 | interface FormData { 12 | email: string; 13 | password: string; 14 | } 15 | 16 | function Login() { 17 | const [formData, setFormData] = useState({ 18 | email: "", 19 | password: "", 20 | }); 21 | 22 | const { email, password } = formData; 23 | 24 | const n = useNavigate(); 25 | const dispatch = useDispatch(); 26 | 27 | const { user, isLoading, isError, isSuccess, message } = useSelector( 28 | (state: RootState) => state.auth, 29 | ); 30 | 31 | useEffect(() => { 32 | if (isError) { 33 | toast.error(message); 34 | } 35 | 36 | if (isSuccess || user) { 37 | n("/"); 38 | } 39 | 40 | dispatch(reset()); 41 | }, [user, isError, isSuccess, message, n, dispatch]); 42 | 43 | const onChange = (e: React.ChangeEvent) => { 44 | setFormData({ 45 | ...formData, 46 | [e.target.name]: e.target.value, 47 | }); 48 | }; 49 | 50 | const onSubmit = async (e: React.FormEvent) => { 51 | e.preventDefault(); 52 | 53 | const userData = { 54 | email, 55 | password, 56 | }; 57 | //FIXME: Type error 58 | dispatch(login(userData as UserData)); 59 | }; 60 | 61 | if (isLoading) { 62 | return ; 63 | } 64 | 65 | return ( 66 |
67 |
68 |

Login

69 |

Please login to your account

70 |
71 | 81 | 91 | 94 |
95 |

96 | Don't have an account?{" "} 97 | 98 | Register here 99 | 100 |

101 |
102 |
103 | ); 104 | } 105 | 106 | export default Login; 107 | -------------------------------------------------------------------------------- /frontend/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useSelector, useDispatch } from "react-redux"; 3 | import { Link, useNavigate } from "react-router-dom"; 4 | import { AppDispatch, RootState } from "../app/store"; 5 | import { getExpenses, reset } from "../features/expenses/expenseSlice"; 6 | import ExpenseItem from "../components/ExpenseItem"; 7 | import Loading from "../components/Loading"; 8 | import { ExpenseDocument } from "@shared/types/types"; 9 | import { FaPlus, FaWallet } from "react-icons/fa6"; 10 | import { formatCurrency } from "../utils/currencyFormatter"; 11 | import WeeklyChart from "../components/WeeklyChart"; 12 | 13 | function Home() { 14 | const n = useNavigate(); 15 | const dispatch = useDispatch(); 16 | 17 | const { user } = useSelector((state: RootState) => state.auth); 18 | const { expenses, isLoading, isError, message } = useSelector( 19 | (state: RootState) => state.expenses, 20 | ); 21 | 22 | useEffect(() => { 23 | if (!user) { 24 | n("/login"); 25 | } 26 | dispatch(getExpenses({} as ExpenseDocument)); 27 | return () => { 28 | dispatch(reset()); 29 | }; 30 | }, [user, n, isError, dispatch, message]); 31 | 32 | if (isLoading) { 33 | return ; 34 | } 35 | 36 | const sortedExpenses = [...(expenses as ExpenseDocument[])].sort((a, b) => { 37 | const dateA = new Date(a.customDate || a.createdAt); 38 | const dateB = new Date(b.customDate || b.createdAt); 39 | return dateB.getTime() - dateA.getTime(); 40 | }); 41 | 42 | // Calculate total income for all time 43 | const totalIncome = (expenses as ExpenseDocument[]) 44 | .filter((expense) => expense.type === "income") 45 | .reduce((total, expense) => total + (expense.amount || 0), 0); 46 | 47 | // Calculate total expenses for all time 48 | const totalExpenses = (expenses as ExpenseDocument[]) 49 | .filter((expense) => expense.type === "expense") 50 | .reduce((total, expense) => total + (expense.amount || 0), 0); 51 | 52 | // Calculate actual balance 53 | const actualBalance = totalIncome - totalExpenses; 54 | 55 | return ( 56 |
57 |

Hello, {user && user.name} 👋

58 | 59 |
= 0 ? "bg-primary" : "bg-error" 62 | } text-primary-content`} 63 | > 64 |
65 |

My Balance

66 |

{formatCurrency(actualBalance)}

67 |
68 |
69 | 70 |
71 |
72 | 73 | 74 | 75 |
76 |

Recent Transactions

77 | {sortedExpenses.length > 0 ? ( 78 |
79 | {sortedExpenses.map((expense) => ( 80 | 81 | ))} 82 |
83 | ) : ( 84 |

No expenses found

85 | )} 86 |
87 | 88 | 96 | 97 |
98 | ); 99 | } 100 | 101 | export default Home; 102 | -------------------------------------------------------------------------------- /frontend/src/components/WeeklyChart.tsx: -------------------------------------------------------------------------------- 1 | import { ExpenseDocument } from "@shared/types/types"; 2 | import { formatCurrency } from "../utils/currencyFormatter"; 3 | 4 | interface WeeklyChartProps { 5 | expenses: ExpenseDocument[]; 6 | } 7 | 8 | export default function WeeklyChart({ expenses }: WeeklyChartProps) { 9 | const now = new Date(); 10 | const startOfWeek = new Date(now); 11 | startOfWeek.setDate(now.getDate() - now.getDay() + 1); // Assuming week starts on Monday 12 | startOfWeek.setHours(0, 0, 0, 0); 13 | 14 | const endOfWeek = new Date(startOfWeek); 15 | endOfWeek.setDate(startOfWeek.getDate() + 6); 16 | 17 | const sortedExpenses = [...expenses].sort((a, b) => { 18 | const dateA = new Date(a.customDate || a.createdAt); 19 | const dateB = new Date(b.customDate || b.createdAt); 20 | return dateB.getTime() - dateA.getTime(); 21 | }); 22 | 23 | const weeklyExpenses = sortedExpenses.filter((expense) => { 24 | const expenseDate = new Date(expense.customDate || expense.createdAt); 25 | return expenseDate >= startOfWeek && expenseDate <= endOfWeek && expense.type === "expense"; 26 | }); 27 | 28 | const dailyExpenses = Array.from({ length: 7 }, (_, i) => { 29 | const date = new Date(startOfWeek); 30 | date.setDate(startOfWeek.getDate() + i); 31 | 32 | const dayExpenses = weeklyExpenses.filter((expense) => { 33 | const expenseDate = new Date(expense.customDate || expense.createdAt); 34 | return ( 35 | expenseDate.getDate() === date.getDate() && 36 | expenseDate.getMonth() === date.getMonth() && 37 | expenseDate.getFullYear() === date.getFullYear() 38 | ); 39 | }); 40 | return dayExpenses.reduce((total, expense) => total + (expense.amount || 0), 0); 41 | }); 42 | 43 | const locale = navigator.language; 44 | const daysOfWeek = Array.from({ length: 7 }, (_, i) => { 45 | const date = new Date(startOfWeek); 46 | date.setDate(startOfWeek.getDate() + i); 47 | return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date); 48 | }); 49 | 50 | const maxDataValue = Math.max(...dailyExpenses, 0); 51 | 52 | const monthlyExpenses = weeklyExpenses.reduce( 53 | (total, expense) => total + (expense.amount || 0), 54 | 0, 55 | ); 56 | 57 | return ( 58 |
59 |
60 |

Spending - This Week

61 |
62 | {dailyExpenses.reduce((acc, value) => acc + value, 0) > 0 ? ( 63 | dailyExpenses.map((value, index) => ( 64 |
74 |
{value.toFixed(0)}
75 |
76 |
{daysOfWeek[index]}
77 |
78 | )) 79 | ) : ( 80 |
81 | No expenses this week 82 |
83 | )} 84 |
85 | 86 |
87 |
88 |
89 |

Total this week

90 |

{formatCurrency(monthlyExpenses)}

91 |
92 |
93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /frontend/src/features/auth/authService.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { UserData } from "@shared/types/types"; 3 | 4 | const API_URL = "api/users/"; 5 | 6 | const register = async (userData: UserData) => { 7 | try { 8 | console.log("Making request to register user:", userData); 9 | const response = await axios.post(API_URL, userData); 10 | console.log("Received response:", response); 11 | 12 | if (response.data) { 13 | localStorage.setItem("user", JSON.stringify(response.data)); 14 | } 15 | 16 | return response.data; 17 | } catch (error) { 18 | console.error("Error registering user:", error); 19 | throw error; 20 | } 21 | }; 22 | 23 | const login = async (userData: Pick) => { 24 | try { 25 | console.log("Making request to login user:", userData); 26 | const response = await axios.post(API_URL + "login", userData); 27 | console.log("Received response:", response); 28 | 29 | if (response.data) { 30 | localStorage.setItem("user", JSON.stringify(response.data)); 31 | } 32 | 33 | return response.data; 34 | } catch (error) { 35 | console.error("Error logging in user:", error); 36 | throw error; 37 | } 38 | }; 39 | 40 | // Update user information 41 | const updateUser = async (userData: Partial) => { 42 | try { 43 | // Get the token from local storage 44 | const user = JSON.parse(localStorage.getItem("user") || "{}"); 45 | 46 | if (!user.token) { 47 | throw new Error("No token found"); 48 | } 49 | 50 | // Make the API call to update user 51 | const response = await axios.put(API_URL + "update", userData, { 52 | headers: { 53 | Authorization: `Bearer ${user.token}`, 54 | }, 55 | }); 56 | 57 | // Update local storage with the new user data 58 | if (response.data) { 59 | localStorage.setItem("user", JSON.stringify(response.data)); 60 | } 61 | 62 | return response.data; 63 | } catch (error) { 64 | console.error("Error updating user:", error); 65 | throw error; 66 | } 67 | }; 68 | 69 | // Upload profile picture 70 | const uploadProfilePicture = async (formData: FormData) => { 71 | try { 72 | const user = JSON.parse(localStorage.getItem("user") || "{}"); 73 | 74 | if (!user.token) { 75 | throw new Error("No token found"); 76 | } 77 | 78 | const response = await axios.post(API_URL + "upload-profile-picture", formData, { 79 | headers: { 80 | Authorization: `Bearer ${user.token}`, 81 | }, 82 | }); 83 | // Update local storage with the new user data 84 | if (response.data) { 85 | localStorage.setItem("user", JSON.stringify(response.data)); 86 | } 87 | 88 | return response.data; 89 | } catch (error) { 90 | console.error("Error uploading profile picture:", error); 91 | throw error; 92 | } 93 | }; 94 | 95 | const logout = () => { 96 | localStorage.removeItem("user"); 97 | }; 98 | const removeProfilePicture = async () => { 99 | try { 100 | const user = JSON.parse(localStorage.getItem("user") || "{}"); 101 | console.log("User from localStorage:", user); 102 | 103 | if (!user.token) { 104 | throw new Error("No token found"); 105 | } 106 | 107 | const response = await axios.post( 108 | API_URL + "remove-profile-picture", 109 | {}, 110 | { 111 | headers: { 112 | Authorization: `Bearer ${user.token}`, 113 | }, 114 | }, 115 | ); 116 | 117 | if (response.data) { 118 | localStorage.setItem("user", JSON.stringify(response.data)); 119 | } 120 | 121 | return response.data; 122 | } catch (error) { 123 | console.error("Error removing profile picture:", error); 124 | throw error; 125 | } 126 | }; 127 | 128 | const authService = { 129 | register, 130 | logout, 131 | login, 132 | updateUser, 133 | uploadProfilePicture, 134 | removeProfilePicture, 135 | }; 136 | export default authService; 137 | -------------------------------------------------------------------------------- /frontend/src/pages/Register.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useSelector, useDispatch } from "react-redux"; 3 | import { Link, useNavigate } from "react-router-dom"; 4 | import { register, reset } from "../features/auth/authSlice"; 5 | import { AppDispatch, RootState } from "../app/store"; 6 | import toast from "react-hot-toast"; 7 | import Loading from "../components/Loading"; 8 | 9 | function Register() { 10 | const [formData, setFormData] = useState({ 11 | name: "", 12 | email: "", 13 | password: "", 14 | password2: "", 15 | }); 16 | 17 | const { name, email, password, password2 } = formData; 18 | 19 | const n = useNavigate(); 20 | const dispatch = useDispatch(); 21 | 22 | const { user, isLoading, isError, isSuccess, message } = useSelector( 23 | (state: RootState) => state.auth, 24 | ); 25 | 26 | useEffect(() => { 27 | if (isError) { 28 | console.log(message); 29 | } 30 | 31 | if (isSuccess || user) { 32 | n("/"); 33 | } 34 | 35 | dispatch(reset()); 36 | }, [user, isError, isSuccess, message, n, dispatch]); 37 | 38 | const onChange = (e: React.ChangeEvent) => { 39 | setFormData((prevState) => ({ 40 | ...prevState, 41 | [e.target.name]: e.target.value, 42 | })); 43 | }; 44 | 45 | const onSubmit = (e: React.FormEvent) => { 46 | e.preventDefault(); 47 | 48 | console.log("Form submitted with data:", formData); 49 | 50 | if (password !== password2) { 51 | toast.error("Passwords do not match"); 52 | } else { 53 | const userData = { 54 | name, 55 | email, 56 | password, 57 | }; 58 | 59 | console.log("Dispatching register action with userData:", userData); 60 | dispatch(register(userData)); 61 | } 62 | }; 63 | 64 | if (isLoading) { 65 | return ; 66 | } 67 | 68 | return ( 69 |
70 |
71 |

Register

72 |

Please create an account

73 |
74 | 83 | 92 | 101 | 110 | 113 |
114 |

115 | Already have an account?{" "} 116 | 117 | Login here 118 | 119 |

120 |
121 |
122 | ); 123 | } 124 | 125 | export default Register; 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

💸 Expense Tracker

4 |

A simple expense tracker application with authentication built with the MERN stack.

5 |

6 | 7 | ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/maciekt07/ExpenseTracker?color=%230061FF) 8 | ![GitHub last commit](https://img.shields.io/github/last-commit/maciekt07/ExpenseTracker?color=%230061FF) 9 | ![GitHub License](https://img.shields.io/github/license/maciekt07/ExpenseTracker?color=%230061FF) 10 | 11 |

12 | 13 |

14 | 15 | ## Tech stack 16 | 17 |
    18 |
  • 19 | react React 20 |
  • 21 |
  • 22 | redux Redux 23 |
  • 24 |
  • 25 | ts TypeScript 26 |
  • 27 |
  • 28 | vite Vite 29 |
  • 30 |
  • 31 | Tailwind Tailwind CSS 32 |
  • 33 |
  • 34 | daisyui DaisyUI 35 |
  • 36 |
  • 37 | nodejs Node.js 38 |
  • 39 |
  • 40 | express Express 41 |
  • 42 |
  • 43 | mongodb MongoDB 44 |
  • 45 |
46 | 47 | ## Features 48 | 49 | - Dark Mode 50 | - Currency Selection with Intl API [https://codepen.io/maciekt07/pen/zYVdPLy](https://codepen.io/maciekt07/pen/zYVdPLy) 51 | - Profile Picture Upload 52 | - JWT Authentication 53 | 54 | ## To run this project locally 55 | 56 | ### 1. Clone the Repository 57 | 58 | First, clone the project repository from GitHub. 59 | 60 | ```bash 61 | git clone https://github.com/maciekt07/ExpenseTracker.git 62 | cd ExpenseTracker 63 | ``` 64 | 65 | ### 2. Configure Environment Variables 66 | 67 | Create a .env file and fill it with your MongoDB token and JSON Web Token (JWT) key. You can use .env.example as a reference for the required format. 68 | 69 | ```env 70 | MONGODB_URI=your_mongodb_token 71 | JWT_SECRET=your_jwt_secret 72 | ``` 73 | 74 | ### 3. Install Backend Dependencies 75 | 76 | ```bash 77 | npm install 78 | ``` 79 | 80 | ### 4. Install Frontend Dependencies 81 | 82 | ```bash 83 | cd frontend 84 | npm install 85 | ``` 86 | 87 | ### 5. Run the Server and Client 88 | 89 | ```bash 90 | cd .. 91 | npm run dev 92 | ``` 93 | 94 | 95 | 96 | The server will start running on port 8000. 97 | 98 | The client will start running on port 5173. 99 | 100 | ## Credits 101 | 102 | Made with ❤️ by [maciekt07](https://github.com/maciekt07). 103 | 104 | Inspired by [Traversy Media Course](https://youtu.be/-0exw-9YJBo?si=Sb0nOUDenxp5Ez3X). 105 | -------------------------------------------------------------------------------- /frontend/src/pages/Add.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { AppDispatch, RootState } from "../app/store"; 4 | import { createExpense } from "../features/expenses/expenseSlice"; 5 | import { useNavigate } from "react-router-dom"; 6 | import toast from "react-hot-toast"; 7 | import { Expense } from "../../../shared/types/types"; 8 | 9 | function Add() { 10 | const [text, setText] = useState(""); 11 | const [amount, setAmount] = useState(undefined); 12 | const [type, setType] = useState("expense"); 13 | const [date, setDate] = useState(null); 14 | 15 | const dispatch = useDispatch(); 16 | const n = useNavigate(); 17 | 18 | const { settings } = useSelector((state: RootState) => state.settings); 19 | 20 | const onSubmit = async (e: React.FormEvent) => { 21 | e.preventDefault(); 22 | 23 | let isoDate = ""; 24 | if (date) { 25 | const parsedDate = new Date(date); 26 | if (!isNaN(parsedDate.getTime())) { 27 | isoDate = parsedDate.toISOString(); 28 | } else { 29 | toast.error("Invalid date"); 30 | return; 31 | } 32 | } 33 | 34 | //FIXME: negative amount should be handled 35 | 36 | await dispatch(createExpense({ text, amount, type, customDate: isoDate } as Expense)); 37 | toast.success(`Added ${type}: ` + text); 38 | setText(""); 39 | setAmount(0); 40 | n("/"); 41 | setDate(null); 42 | }; 43 | 44 | return ( 45 |
46 |
47 |

Add New Expense

48 |
49 |
50 | 53 | setText(e.target.value)} 60 | className="input input-bordered w-full max-w-xs" 61 | required 62 | /> 63 |
64 |
65 | 68 | setAmount(Number(e.target.value))} 74 | placeholder={`Enter amount (${settings.currency})`} 75 | className="input input-bordered w-full max-w-xs" 76 | required 77 | /> 78 |
79 |
80 | 83 | setDate(e.target.value)} 89 | className="input input-bordered w-full max-w-xs" 90 | /> 91 |
92 |
93 | 96 | 107 |
108 | 115 |
116 |
117 |
118 | ); 119 | } 120 | 121 | export default Add; 122 | -------------------------------------------------------------------------------- /backend/controllers/userController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import jwt from "jsonwebtoken"; 3 | import bcrypt from "bcrypt"; 4 | import asyncHandler from "express-async-handler"; 5 | import User from "../models/user"; 6 | import { Types } from "mongoose"; 7 | import { AuthenticatedRequest } from "../types/types"; 8 | import fs from "fs/promises"; 9 | import path from "path"; 10 | 11 | const generateToken = (id: Types.ObjectId) => { 12 | return jwt.sign({ id }, process.env.JWT_SECRET as string, { 13 | expiresIn: "30d", 14 | }); 15 | }; 16 | 17 | export const registerUser = asyncHandler(async (req: Request, res: Response) => { 18 | const { name, email, password } = req.body; 19 | 20 | if (!name || !email || !password) { 21 | res.status(400); 22 | throw new Error("Missing required fields"); 23 | } 24 | 25 | // Check if user already exists 26 | const userExists = await User.findOne({ email }); 27 | if (userExists) { 28 | res.status(400); 29 | throw new Error("User already exists"); 30 | } 31 | 32 | // Hash password 33 | const salt = await bcrypt.genSalt(10); 34 | const hashedPassword = await bcrypt.hash(password, salt); 35 | 36 | // Create user 37 | const user = await User.create({ 38 | name, 39 | email, 40 | password: hashedPassword, 41 | }); 42 | 43 | if (user) { 44 | res.status(201).json({ 45 | _id: user._id, 46 | name: user.name, 47 | profilePicture: user.profilePicture, 48 | email: user.email, 49 | token: generateToken(user._id), 50 | }); 51 | } else { 52 | res.status(400); 53 | throw new Error("Failed to create user"); 54 | } 55 | }); 56 | 57 | export const loginUser = asyncHandler(async (req: Request, res: Response) => { 58 | const { email, password } = req.body; 59 | 60 | // Check for user email 61 | const user = await User.findOne({ email }); 62 | if (user && (await bcrypt.compare(password, user.password))) { 63 | res.json({ 64 | _id: user._id, 65 | name: user.name, 66 | profilePicture: user.profilePicture, 67 | email: user.email, 68 | token: generateToken(user._id), 69 | }); 70 | } else { 71 | res.status(400); 72 | throw new Error("Invalid credentials"); 73 | } 74 | }); 75 | 76 | //https://www.youtube.com/watch?v=UXjMo25Nnvc&list=PLillGF-RfqbbQeVSccR9PGKHzPJSWqcsm&index=4&ab_channel=TraversyMedia 77 | export const getUserData = asyncHandler(async (req: Request, res: Response) => { 78 | if (!(req as AuthenticatedRequest).user) { 79 | res.status(401); 80 | throw new Error("Not authorized"); 81 | } 82 | 83 | const user = await User.findById((req as AuthenticatedRequest).user.id); 84 | if (!user) { 85 | res.status(404); 86 | throw new Error("User not found"); 87 | } 88 | 89 | const { _id, name, email, profilePicture } = user; 90 | res.status(200).json({ 91 | id: _id, 92 | name, 93 | email, 94 | profilePicture, 95 | }); 96 | }); 97 | 98 | export const updateUser = asyncHandler(async (req: Request, res: Response) => { 99 | const user = await User.findById((req as AuthenticatedRequest).user.id); 100 | 101 | if (!user) { 102 | res.status(401); 103 | throw new Error("User not found"); 104 | } 105 | 106 | if (req.body.name && typeof req.body.name !== "string") { 107 | res.status(400); 108 | throw new Error("Invalid name"); 109 | } 110 | 111 | if (req.body.email && typeof req.body.email !== "string") { 112 | res.status(400); 113 | throw new Error("Invalid email"); 114 | } 115 | 116 | const updatedUser = await User.findByIdAndUpdate( 117 | (req as AuthenticatedRequest).user.id, 118 | req.body, 119 | { new: true, runValidators: true }, 120 | ); 121 | 122 | if (!updatedUser) { 123 | res.status(400); 124 | throw new Error("Failed to update user"); 125 | } 126 | 127 | res.status(200).json({ 128 | _id: updatedUser._id, 129 | name: updatedUser.name, 130 | email: updatedUser.email, 131 | token: generateToken(updatedUser._id), 132 | profilePicture: updatedUser.profilePicture, 133 | }); 134 | }); 135 | 136 | export const uploadProfilePicture = asyncHandler(async (req: Request, res: Response) => { 137 | if (!(req as AuthenticatedRequest).user) { 138 | res.status(401); 139 | throw new Error("Not authorized"); 140 | } 141 | 142 | const user = await User.findById((req as AuthenticatedRequest).user.id); 143 | if (!user) { 144 | res.status(404); 145 | throw new Error("User not found"); 146 | } 147 | 148 | if (!req.file) { 149 | res.status(400); 150 | throw new Error("No file uploaded"); 151 | } 152 | 153 | user.profilePicture = req.file.path; 154 | 155 | await user.save(); 156 | 157 | res.status(200).json({ 158 | _id: user._id, 159 | name: user.name, 160 | email: user.email, 161 | token: generateToken(user._id), 162 | profilePicture: user.profilePicture, 163 | }); 164 | }); 165 | 166 | export const removeProfilePicture = asyncHandler(async (req: Request, res: Response) => { 167 | if (!(req as AuthenticatedRequest).user) { 168 | res.status(401); 169 | throw new Error("Not authorized"); 170 | } 171 | 172 | const user = await User.findById((req as AuthenticatedRequest).user.id); 173 | if (!user) { 174 | res.status(404); 175 | throw new Error("User not found"); 176 | } 177 | 178 | const uploadDir = "uploads"; 179 | 180 | // Delete profile picture file 181 | if (user.profilePicture) { 182 | const filePath = path.join(uploadDir, path.basename(user.profilePicture)); 183 | try { 184 | await fs.unlink(filePath); 185 | console.log(`Deleted profile picture file: ${filePath}`); 186 | } catch (err) { 187 | console.error("Failed to delete file:", err); 188 | } 189 | } 190 | 191 | // Clear profile picture reference in the database 192 | user.profilePicture = undefined; 193 | await user.save(); 194 | 195 | res.status(200).json({ 196 | _id: user._id, 197 | name: user.name, 198 | email: user.email, 199 | token: generateToken(user._id), 200 | profilePicture: undefined, 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /frontend/src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Link, useNavigate } from "react-router-dom"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { logout, reset } from "../features/auth/authSlice"; 5 | import { AppDispatch, RootState } from "../app/store"; 6 | import logo from "../assets/logo512.png"; 7 | import { FaBars, FaTimes } from "react-icons/fa"; 8 | import { FaArrowRightFromBracket } from "react-icons/fa6"; 9 | import ThemeSwitcher from "./ThemeSwitch"; 10 | 11 | function Navbar() { 12 | const [isSidebarOpen, setIsSidebarOpen] = useState(false); 13 | const navigate = useNavigate(); 14 | const dispatch = useDispatch(); 15 | const { user } = useSelector((state: RootState) => state.auth); 16 | 17 | const handleLogout = () => { 18 | dispatch(logout()); 19 | dispatch(reset()); 20 | navigate("/"); 21 | }; 22 | 23 | const closeSidebar = () => setIsSidebarOpen(false); 24 | 25 | return ( 26 |
27 | {/* Backdrop */} 28 | {isSidebarOpen &&
} 29 | 30 | {/* Sidebar */} 31 |
36 |
37 |
38 | 41 |
42 | 43 |
44 | 45 | {user ? ( 46 | <> 47 | 52 | Home 53 | 54 | 63 | 64 | ) : ( 65 | <> 66 | 71 | Login 72 | 73 | 78 | Register 79 | 80 | 81 | )} 82 |
83 | {user && ( 84 |
85 | 86 | {user.profilePicture ? ( 87 | Profile 92 | ) : ( 93 |
94 | {user.name ? user.name[0] : ""} 95 |
96 | )} 97 | {user.name} 98 | 99 |
100 | )} 101 |
102 |
103 | 104 | {/* Navbar */} 105 | 154 |
155 | ); 156 | } 157 | 158 | export default Navbar; 159 | -------------------------------------------------------------------------------- /frontend/src/pages/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { AppDispatch, RootState } from "../app/store"; 4 | import { 5 | removeProfilePicture, 6 | reset, 7 | updateUser, 8 | uploadProfilePicture, 9 | } from "../features/auth/authSlice"; 10 | import toast from "react-hot-toast"; 11 | import { useNavigate } from "react-router-dom"; 12 | import { updateSettings } from "../features/settings/settingsSlice"; 13 | import { Settings } from "../types/types"; 14 | 15 | function UserProfile() { 16 | const { user, isLoading, isError, isSuccess, message } = useSelector( 17 | (state: RootState) => state.auth, 18 | ); 19 | const dispatch = useDispatch(); 20 | const { settings } = useSelector((state: RootState) => state.settings); 21 | const [name, setName] = useState(user?.name || ""); 22 | const [selectedCurrency, setSelectedCurrency] = useState( 23 | settings.currency || "USD", 24 | ); 25 | 26 | const n = useNavigate(); 27 | 28 | const onSubmit = (e: React.FormEvent) => { 29 | e.preventDefault(); 30 | dispatch(updateUser({ name })); 31 | }; 32 | 33 | useEffect(() => { 34 | if (isSuccess) { 35 | toast.success("User updated successfully!"); 36 | dispatch(reset()); 37 | } 38 | if (isError) { 39 | toast.error(`Error: ${message}`); 40 | } 41 | 42 | if (!user) { 43 | n("/login"); 44 | } 45 | }, [isSuccess, isError, message, user, n, dispatch]); 46 | 47 | const onFileChange = (e: React.ChangeEvent) => { 48 | const file = e.target.files?.[0]; 49 | if (file) { 50 | const allowedFileTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]; 51 | if (!allowedFileTypes.includes(file.type)) { 52 | toast.error("Invalid file type."); 53 | return; 54 | } 55 | dispatch(uploadProfilePicture(file)); 56 | } 57 | }; 58 | 59 | const onRemoveProfilePicture = () => { 60 | dispatch(removeProfilePicture()); 61 | }; 62 | 63 | //@ts-expect-error it works :) 64 | const currencies: Settings["currency"][] = Intl.supportedValuesOf("currency"); 65 | 66 | const displayNames = new Intl.DisplayNames([navigator.language], { 67 | type: "currency", 68 | }); 69 | 70 | const onCurrencyChange = (e: React.ChangeEvent) => { 71 | const newCurrency = e.target.value; 72 | setSelectedCurrency(newCurrency); 73 | dispatch( 74 | updateSettings({ 75 | ...settings, 76 | currency: newCurrency, 77 | }), 78 | ); 79 | }; 80 | return ( 81 |
82 |
83 |

User Profile

84 | {user?.profilePicture ? ( 85 |
86 |
87 | Profile 91 |
92 |
93 | ) : ( 94 |
95 |
96 | {user?.name ? user.name[0] : "?"} 97 |
98 |
99 | )} 100 | 101 | 107 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 |
Name{user?.name}
Email{user?.email}
123 |
124 |

Choose your currency

125 |
126 | 127 | 142 |
143 |
144 |
145 |

Update Your Profile

146 |
147 | 148 | setName(e.target.value)} 152 | className="input input-bordered w-full" 153 | /> 154 |
155 | {name !== user?.name && name.length > 2 && ( 156 | 163 | )} 164 |
165 |
166 |
167 | ); 168 | } 169 | 170 | export default UserProfile; 171 | -------------------------------------------------------------------------------- /frontend/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /frontend/src/features/expenses/expenseSlice.ts: -------------------------------------------------------------------------------- 1 | import { AsyncThunk, createAsyncThunk, createSlice } from "@reduxjs/toolkit"; 2 | 3 | import { RootState } from "../../app/store"; 4 | import expenseService from "./expenseService"; 5 | import { Expense, ExpenseDocument } from "@shared/types/types"; 6 | 7 | interface AsyncThunkConfig {} 8 | 9 | const initialState = { 10 | expenses: [] as Expense[], 11 | expense: null as Expense | null, 12 | isError: false, 13 | isSuccess: false, 14 | isLoading: false, 15 | message: "", 16 | }; 17 | 18 | // create new expense 19 | 20 | export const createExpense: AsyncThunk = createAsyncThunk( 21 | "expsense/create", 22 | async (expenseData: Expense, thunkAPI) => { 23 | try { 24 | const state = thunkAPI.getState() as RootState; 25 | const token = state.auth.user?.token; 26 | return await expenseService.createExpense(expenseData, token || ""); 27 | 28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 29 | } catch (error: any) { 30 | console.error("Error in register thunk:", error); 31 | const message = 32 | (error.response && error.response.data && error.response.data.message) || 33 | error.message || 34 | error.toString(); 35 | return thunkAPI.rejectWithValue(message); 36 | } 37 | }, 38 | ); 39 | 40 | // Get user expenses 41 | export const getExpenses: AsyncThunk = createAsyncThunk( 42 | "expense/getAll", 43 | async (_, thunkAPI) => { 44 | try { 45 | const state = thunkAPI.getState() as RootState; 46 | const token = state.auth.user?.token; 47 | console.log(await expenseService.getExpenses(token || "")); 48 | 49 | return await expenseService.getExpenses(token || ""); 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | } catch (error: any) { 52 | const message = 53 | (error.response && error.response.data && error.response.data.message) || 54 | error.message || 55 | error.toString(); 56 | return thunkAPI.rejectWithValue(message); 57 | } 58 | }, 59 | ); 60 | 61 | export const getExpenseDetails = createAsyncThunk( 62 | "expense/getDetails", 63 | async (id: string, thunkAPI) => { 64 | try { 65 | const state = thunkAPI.getState(); 66 | const token = state.auth.user?.token; 67 | if (!token) { 68 | throw new Error("No authentication token available"); 69 | } 70 | return await expenseService.getExpenseDetails(id, token); 71 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 72 | } catch (error: any) { 73 | const message = 74 | error.response?.data?.message || error.message || "Failed to fetch expense details"; 75 | return thunkAPI.rejectWithValue(message); 76 | } 77 | }, 78 | ); 79 | 80 | // Delete expense 81 | export const deleteExpense: AsyncThunk<{ id: string }, string, AsyncThunkConfig> = createAsyncThunk( 82 | "expsense/delete", 83 | async (id: string, thunkAPI) => { 84 | try { 85 | const state = thunkAPI.getState() as RootState; 86 | const token = state.auth.user?.token; 87 | await expenseService.deleteExpense(id, token || ""); 88 | return { id }; 89 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 90 | } catch (error: any) { 91 | console.error("Error in register thunk:", error); 92 | const message = 93 | (error.response && error.response.data && error.response.data.message) || 94 | error.message || 95 | error.toString(); 96 | return thunkAPI.rejectWithValue(message); 97 | } 98 | }, 99 | ); 100 | 101 | export const updateExpense = createAsyncThunk( 102 | "expense/update", 103 | async (expenseData: Expense, thunkAPI) => { 104 | try { 105 | const state = thunkAPI.getState() as RootState; 106 | const token = state.auth.user?.token; 107 | return await expenseService.updateExpense(expenseData, token || ""); 108 | } catch (error) { 109 | return thunkAPI.rejectWithValue("Failed to update expense"); 110 | } 111 | }, 112 | ); 113 | 114 | export const expenseSlice = createSlice({ 115 | name: "expense", 116 | initialState, 117 | reducers: { 118 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 119 | reset: (_state) => initialState, 120 | }, 121 | extraReducers: (builder) => { 122 | builder 123 | .addCase(createExpense.pending, (state) => { 124 | state.isLoading = true; 125 | }) 126 | .addCase(createExpense.fulfilled, (state, action) => { 127 | state.isLoading = false; 128 | state.isSuccess = true; 129 | state.expenses.push(action.payload); 130 | }) 131 | .addCase(createExpense.rejected, (state, action) => { 132 | state.isLoading = false; 133 | state.isError = true; 134 | state.message = action.payload as string; 135 | }) 136 | // Get user expenses 137 | .addCase(getExpenses.pending, (state) => { 138 | state.isLoading = true; 139 | }) 140 | .addCase(getExpenses.fulfilled, (state, action) => { 141 | state.isLoading = false; 142 | state.isSuccess = true; 143 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 144 | state.expenses = action.payload as any; 145 | }) 146 | .addCase(getExpenses.rejected, (state, action) => { 147 | state.isLoading = false; 148 | state.isError = true; 149 | state.message = action.payload as string; 150 | }) 151 | 152 | // Delete expense 153 | .addCase(deleteExpense.pending, (state) => { 154 | state.isLoading = true; 155 | }) 156 | .addCase(deleteExpense.fulfilled, (state, action) => { 157 | state.isLoading = false; 158 | state.isSuccess = true; 159 | 160 | state.expenses = state.expenses.filter( 161 | (expense) => (expense as ExpenseDocument)._id !== action.payload.id, 162 | ); 163 | }) 164 | .addCase(deleteExpense.rejected, (state, action) => { 165 | state.isLoading = false; 166 | state.isError = true; 167 | state.message = action.payload as string; 168 | }) 169 | .addCase(updateExpense.fulfilled, (state, action) => { 170 | state.isLoading = false; 171 | state.isSuccess = true; 172 | const index = state.expenses.findIndex((expense) => expense._id === action.payload._id); 173 | if (index !== -1) { 174 | state.expenses[index] = action.payload; 175 | } 176 | }) 177 | .addCase(updateExpense.rejected, (state, action) => { 178 | state.isLoading = false; 179 | state.isError = true; 180 | state.message = action.payload as string; 181 | }) 182 | .addCase(getExpenseDetails.pending, (state) => { 183 | state.isLoading = true; 184 | }) 185 | .addCase(getExpenseDetails.fulfilled, (state, action) => { 186 | state.isLoading = false; 187 | state.isSuccess = true; 188 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 189 | state.expense = action.payload as Expense; 190 | }) 191 | .addCase(getExpenseDetails.rejected, (state, action) => { 192 | state.isLoading = false; 193 | state.isError = true; 194 | state.message = action.payload as string; 195 | }); 196 | }, 197 | }); 198 | 199 | export const { reset } = expenseSlice.actions; 200 | export default expenseSlice.reducer; 201 | -------------------------------------------------------------------------------- /frontend/src/features/auth/authSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk, AsyncThunk } from "@reduxjs/toolkit"; 2 | import authService from "./authService"; 3 | import { UserData } from "@shared/types/types"; 4 | 5 | interface AsyncThunkConfig {} 6 | 7 | const user = JSON.parse(localStorage.getItem("user") || "null"); 8 | 9 | const initialState = { 10 | user: (user as UserData | null) || null, 11 | isError: false, 12 | isSuccess: false, 13 | isLoading: false, 14 | message: "", 15 | }; 16 | 17 | // Register user 18 | 19 | export const register: AsyncThunk = createAsyncThunk( 20 | "auth/register", 21 | async (user: UserData, thunkAPI) => { 22 | try { 23 | console.log("Dispatching register action with user:", user); 24 | return await authService.register(user); 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | } catch (error: any) { 27 | console.error("Error in register thunk:", error); 28 | const message = 29 | (error.response && error.response.data && error.response.data.message) || 30 | error.message || 31 | error.toString(); 32 | return thunkAPI.rejectWithValue(message); 33 | } 34 | }, 35 | ); 36 | 37 | export const logout = createAsyncThunk( 38 | "auth/logout", 39 | async (_, thunkAPI) => { 40 | try { 41 | authService.logout(); 42 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 | } catch (error: any) { 44 | console.error("Error logging out:", error); 45 | return thunkAPI.rejectWithValue(error.message || error.toString()); 46 | } 47 | }, 48 | ); 49 | 50 | export const login: AsyncThunk = createAsyncThunk( 51 | "auth/login", 52 | async (user: UserData, thunkAPI) => { 53 | try { 54 | console.log("Dispatching login action with user:", user); 55 | return await authService.login(user); 56 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 57 | } catch (error: any) { 58 | console.error("Error in login thunk:", error); 59 | const message = 60 | (error.response && error.response.data && error.response.data.message) || 61 | error.message || 62 | error.toString(); 63 | return thunkAPI.rejectWithValue(message); 64 | } 65 | }, 66 | ); 67 | 68 | export const updateUser = createAsyncThunk, AsyncThunkConfig>( 69 | "auth/updateUser", 70 | async (userData, thunkAPI) => { 71 | try { 72 | return await authService.updateUser(userData); 73 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 74 | } catch (error: any) { 75 | const message = error.response?.data?.message || error.message || error.toString(); 76 | return thunkAPI.rejectWithValue(message); 77 | } 78 | }, 79 | ); 80 | 81 | export const uploadProfilePicture = createAsyncThunk>( 82 | "auth/uploadProfilePicture", 83 | async (file: File, thunkAPI) => { 84 | try { 85 | // Create FormData and append the file 86 | const formData = new FormData(); 87 | formData.append("profilePicture", file); 88 | // Call the authService to upload the file 89 | return await authService.uploadProfilePicture(formData); 90 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 91 | } catch (error: any) { 92 | // Handle error 93 | const message = 94 | (error.response && error.response.data && error.response.data.message) || 95 | error.message || 96 | error.toString(); 97 | return thunkAPI.rejectWithValue(message); 98 | } 99 | }, 100 | ); 101 | 102 | export const removeProfilePicture = createAsyncThunk< 103 | void, 104 | void, 105 | { rejectValue: string } // Provide `rejectValue` type to handle errors 106 | >("auth/removeProfilePicture", async (_, thunkAPI) => { 107 | try { 108 | await authService.removeProfilePicture(); 109 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 110 | } catch (error: any) { 111 | // Handle error 112 | const message = 113 | (error.response?.data?.message as string) || 114 | error.message || 115 | "Failed to remove profile picture"; 116 | return thunkAPI.rejectWithValue(message); 117 | } 118 | }); 119 | 120 | export const authSlice = createSlice({ 121 | name: "auth", 122 | initialState, 123 | reducers: { 124 | reset: (state) => { 125 | state.isLoading = false; 126 | state.isError = false; 127 | state.isSuccess = false; 128 | state.message = ""; 129 | }, 130 | }, 131 | extraReducers: (builder) => { 132 | builder // Register 133 | .addCase(register.pending, (state) => { 134 | state.isLoading = true; 135 | }) 136 | .addCase(register.fulfilled, (state, action) => { 137 | state.isLoading = false; 138 | state.isSuccess = true; 139 | state.user = action.payload; 140 | }) 141 | .addCase(register.rejected, (state, action) => { 142 | state.isLoading = false; 143 | state.isError = true; 144 | state.message = action.payload as string; 145 | state.user = null; 146 | }) // Login 147 | .addCase(login.pending, (state) => { 148 | state.isLoading = true; 149 | }) 150 | .addCase(login.fulfilled, (state, action) => { 151 | state.isLoading = false; 152 | state.isSuccess = true; 153 | state.user = action.payload; 154 | }) 155 | .addCase(login.rejected, (state, action) => { 156 | state.isLoading = false; 157 | state.isError = true; 158 | state.message = action.payload as string; 159 | state.user = null; 160 | }) 161 | .addCase(logout.fulfilled, (state) => { 162 | state.user = null; 163 | }) 164 | .addCase(updateUser.pending, (state) => { 165 | state.isLoading = true; 166 | }) 167 | .addCase(updateUser.fulfilled, (state, action) => { 168 | state.isLoading = false; 169 | state.isSuccess = true; 170 | state.user = action.payload; 171 | }) 172 | .addCase(updateUser.rejected, (state, action) => { 173 | state.isLoading = false; 174 | state.isError = true; 175 | state.message = action.payload as string; 176 | }) 177 | .addCase(uploadProfilePicture.pending, (state) => { 178 | state.isLoading = true; 179 | }) 180 | .addCase(uploadProfilePicture.fulfilled, (state, action) => { 181 | state.isLoading = false; 182 | state.isSuccess = true; 183 | state.user = action.payload; 184 | }) 185 | .addCase(uploadProfilePicture.rejected, (state, action) => { 186 | state.isLoading = false; 187 | state.isError = true; 188 | state.message = action.payload as string; 189 | }) 190 | .addCase(removeProfilePicture.pending, (state) => { 191 | state.isLoading = true; 192 | }) 193 | .addCase(removeProfilePicture.fulfilled, (state) => { 194 | state.isLoading = false; 195 | state.isSuccess = true; 196 | if (state.user) { 197 | state.user = { ...state.user, profilePicture: "" }; 198 | } 199 | }) 200 | .addCase(removeProfilePicture.rejected, (state, action) => { 201 | state.isLoading = false; 202 | state.isError = true; 203 | state.message = action.payload as string; 204 | }); 205 | }, 206 | }); 207 | 208 | export const { reset } = authSlice.actions; 209 | export default authSlice.reducer; 210 | --------------------------------------------------------------------------------