diff --git a/docker-compose.yml b/docker-compose.yml index 3c813eb..0bb4fd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,26 +1,21 @@ version: '3.1' services: - web: - build: - context: . - dockerfile: Dockerfile - container_name: eagle-ec-fe-container - image: mugemanebertin/eagle-ec-fe - ports: - - "5173:5173" - env_file: - - .env - + # Backend Service backend: image: mugemanebertin/eagle_ec_be:latest - container_name: eagle-ec-be-container + container_name: express-server-container ports: - - "499:499" - command: sh -c "npm run migrate && (npm run seed || true) && npm run dev" + - "${PORT}:${PORT}" + volumes: + - ./backend:/usr/src/app + - /usr/src/app/node_modules + command: sh -c "npm run migrate && npm run seed || true && npm run dev" depends_on: - - db + - postgres_db - redis + env_file: + - ./.env environment: - DB_CONNECTION=${DOCKER_DB_CONNECTION} - JWT_SECRET=${JWT_SECRET} @@ -35,12 +30,14 @@ services: - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL} - - REDIS_HOST=redis - - REDIS_PORT=6379 + - FE_URL=${FE_URL} + networks: + - eagle-ec - db: + # PostgreSQL Database Service + postgres_db: image: postgres:latest - container_name: eagle-ec-db-container + container_name: postgres-db-container ports: - "5433:5432" environment: @@ -49,11 +46,41 @@ services: - POSTGRES_DB=${POSTGRES_DB} volumes: - postgres_data:/var/lib/postgresql/data + networks: + - eagle-ec + # Redis Service redis: image: redis:latest + container_name: redis-container ports: - "6379:6379" + networks: + - eagle-ec + + # Web Frontend Service + frontend: + build: + context: . + dockerfile: Dockerfile + container_name: eagle-ec-fe-container + image: mugemanebertin/eagle-ec-fe + ports: + - "5173:5173" + env_file: + - ./.env + volumes: + - ./frontend:/app + - /app/node_modules + networks: + - eagle-ec + command: npm run dev -- --host + depends_on: + - backend volumes: - postgres_data: \ No newline at end of file + postgres_data: + +networks: + eagle-ec: + driver: bridge diff --git a/src/__test__/HomeButton.test.tsx b/src/__test__/HomeButton.test.tsx new file mode 100644 index 0000000..7baa2f8 --- /dev/null +++ b/src/__test__/HomeButton.test.tsx @@ -0,0 +1,20 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; + +import HomeButton from "../components/dashboard/HomeButton"; + +describe("HomeButton", () => { + it("should render the Home button", () => { + render( + + + } /> + + , + ); + + const homeButton = screen.getByText("Home"); + expect(homeButton).toBeInTheDocument(); + }); +}); diff --git a/src/__test__/LogoutContext.test.tsx b/src/__test__/LogoutContext.test.tsx new file mode 100644 index 0000000..9acefd9 --- /dev/null +++ b/src/__test__/LogoutContext.test.tsx @@ -0,0 +1,62 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { + LogoutProvider, + useLogout, +} from "../components/dashboard/admin/LogoutContext"; + +// Mock LogoutModal component +jest.mock("../components/dashboard/admin/LogoutModal", () => ({ + __esModule: true, + default: ({ isOpen, onClose, onConfirm }: any) => + (isOpen ? ( +
+
+

Confirm Logout

+

Are you sure you want to logout?

+
+ + +
+
+
+ ) : null), +})); + +const TestComponent: React.FC = () => { + const { openLogoutModal } = useLogout(); + return ; +}; + +describe("LogoutProvider Component", () => { + it("should render LogoutProvider and trigger logout modal", () => { + render( + + + , + ); + + const openModalButton = screen.getByText("Open Logout Modal"); + fireEvent.click(openModalButton); + + // Verify that the modal is rendered + expect(screen.getByText("Confirm Logout")).toBeInTheDocument(); + expect( + screen.getByText("Are you sure you want to logout?"), + ).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.getByText("Logout")).toBeInTheDocument(); + }); +}); diff --git a/src/__test__/ProfileDropdown.test.tsx b/src/__test__/ProfileDropdown.test.tsx new file mode 100644 index 0000000..c5459a1 --- /dev/null +++ b/src/__test__/ProfileDropdown.test.tsx @@ -0,0 +1,97 @@ +import "@testing-library/jest-dom"; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { BrowserRouter as Router } from "react-router-dom"; + +import { useLogout } from "../components/dashboard/admin/LogoutContext"; // Ensure the correct path +import ProfileDropdown from "../components/common/ProfileDropdown"; + +// Mock the useLogout hook +jest.mock("../components/dashboard/admin/LogoutContext", () => ({ + useLogout: jest.fn(), +})); + +describe("ProfileDropdown", () => { + const openLogoutModalMock = jest.fn(); + + beforeEach(() => { + (useLogout as jest.Mock).mockReturnValue({ + openLogoutModal: openLogoutModalMock, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render profile and orders links for a user with roleId 1", () => { + const userInfo = { roleId: 1 }; + + render( + + + , + ); + + expect(screen.getByText("Profile")).toBeInTheDocument(); + expect(screen.getByText("My Orders")).toBeInTheDocument(); + expect(screen.getByText("Logout")).toBeInTheDocument(); + }); + + it("should render the correct dashboard link for roleId 2", () => { + const userInfo = { roleId: 2 }; + + render( + + + , + ); + + expect(screen.getByText("My Dashboard")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /My Dashboard/i })).toHaveAttribute( + "href", + "/dashboard", + ); + }); + + it("should render the correct dashboard link for roleId 3", () => { + const userInfo = { roleId: 3 }; + + render( + + + , + ); + + expect(screen.getByText("My Dashboard")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /My Dashboard/i })).toHaveAttribute( + "href", + "/admin/dashboard", + ); + }); + + it("should call openLogoutModal when logout button is clicked", () => { + const userInfo = { roleId: 1 }; + + render( + + + , + ); + + const logoutButton = screen.getByText("Logout"); + fireEvent.click(logoutButton); + + expect(openLogoutModalMock).toHaveBeenCalled(); + }); + + it("should render login link if userInfo is not provided", () => { + render( + + + , + ); + + expect(screen.getByText("Login")).toBeInTheDocument(); + }); +}); diff --git a/src/__test__/addProducts.test.tsx b/src/__test__/addProducts.test.tsx index ba3524a..6624942 100644 --- a/src/__test__/addProducts.test.tsx +++ b/src/__test__/addProducts.test.tsx @@ -16,6 +16,7 @@ import TextInput from "../components/common/TextInput"; import AddProduct from "../dashboard/sellers/AddProduct"; import FileUpload from "../components/dashboard/FileUpload"; import { fetchCategories } from "../redux/reducers/categoriesSlice"; +import { LogoutProvider } from "../components/dashboard/admin/LogoutContext"; beforeAll(() => { const mockPayload = { @@ -64,7 +65,13 @@ describe("FileUpload component", () => { }); it("should render the component with no files", () => { - render(); + render( + + {" "} + {/* Wrap with LogoutProvider */} + + , + ); expect(screen.getByText("Browse Images...")).toBeInTheDocument(); expect(screen.getByText(/Browse Images.../i)).toBeInTheDocument(); @@ -73,14 +80,22 @@ describe("FileUpload component", () => { it("should render the component with files", () => { render( - , + + {" "} + {/* Wrap with LogoutProvider */} + + , ); expect(screen.getByRole("button", { name: /remove/i })).toBeInTheDocument(); }); it("should call remove when the remove button is clicked", () => { render( - , + + {" "} + {/* Wrap with LogoutProvider */} + + , ); const removeButton = screen.getByRole("button", { name: /remove/i }); @@ -95,9 +110,13 @@ describe("test seller dashboard components", () => { render( - -

Seller's dashboard

-
+ + {" "} + {/* Wrap with LogoutProvider */} + +

Seller's dashboard

+
+
, ); @@ -110,7 +129,11 @@ describe("test seller dashboard components", () => { render( - + + {" "} + {/* Wrap with LogoutProvider */} + + , ); @@ -142,11 +165,15 @@ describe("test seller dashboard components", () => { render( - + + {" "} + {/* Wrap with LogoutProvider */} + + , ); @@ -169,12 +196,16 @@ describe("test seller dashboard components", () => { const Component = () => { const { register } = useForm(); return ( - + + {" "} + {/* Wrap with LogoutProvider */} + + ); }; @@ -198,13 +229,17 @@ describe("test seller dashboard components", () => { const Component = () => { const { register } = useForm(); return ( - + + {" "} + {/* Wrap with LogoutProvider */} + + ); }; @@ -225,7 +260,11 @@ describe("test seller dashboard components", () => { render( - + + {" "} + {/* Wrap with LogoutProvider */} + + , ); @@ -261,7 +300,11 @@ describe("test seller dashboard components", () => { render( - + + {" "} + {/* Wrap with LogoutProvider */} + + , ); @@ -278,7 +321,11 @@ it("should display error messages for invalid inputs", async () => { render( - + + {" "} + {/* Wrap with LogoutProvider */} + + , ); @@ -294,7 +341,11 @@ it("should open and close AddCategory modal", () => { render( - + + {" "} + {/* Wrap with LogoutProvider */} + + , ); diff --git a/src/__test__/login.test.tsx b/src/__test__/login.test.tsx index 304065c..470224c 100644 --- a/src/__test__/login.test.tsx +++ b/src/__test__/login.test.tsx @@ -11,6 +11,7 @@ import Login from "../pages/Login"; import store from "../redux/store"; import { login } from "../redux/api/loginApiSlice"; +// Mock Adapter for API requests const mock = new MockAdapter(api); const mockData = { @@ -18,36 +19,40 @@ const mockData = { token: "mockAccessToken", }; +// Setup and teardown for network requests const mockNetworkRequests = () => { - mock.onPost("/").reply(200, mockData); + mock.onPost("/login").reply(200, mockData); }; + const unMockNetworkRequests = () => { mock.resetHistory(); }; -beforeEach(() => { - mockNetworkRequests(); -}); - -afterEach(() => { - unMockNetworkRequests(); -}); - +// Mock localStorage const localStorageMock = (() => { - let lstore: Record = {}; + let store: Record = {}; return { - getItem: (key: string) => lstore[key] || null, + getItem: (key: string) => store[key] || null, setItem: (key: string, value: string) => { - lstore[key] = value?.toString(); + store[key] = value?.toString(); }, clear: () => { - lstore = {}; + store = {}; }, }; })(); + Object.defineProperty(window, "localStorage", { value: localStorageMock }); -describe("test user login", () => { +beforeEach(() => { + mockNetworkRequests(); +}); + +afterEach(() => { + unMockNetworkRequests(); +}); + +describe("Login Component", () => { test("should render login page", () => { render( @@ -57,20 +62,126 @@ describe("test user login", () => { , ); - const logo = screen.getByText("eagles"); + + const logo = screen.getByText(/login to/i); // Adjust if needed const emailInput = screen.getByPlaceholderText("Email"); const passwordInput = screen.getByPlaceholderText("Password"); const loginButton = screen.getByRole("button", { name: /login/i }); expect(logo).toBeInTheDocument(); - userEvent.type(emailInput, "jaboinnovates@gmail.com"); - userEvent.type(passwordInput, "Test@123"); + expect(emailInput).toBeInTheDocument(); + expect(passwordInput).toBeInTheDocument(); + expect(loginButton).toBeInTheDocument(); + }); + + test("should handle form validation", async () => { + render( + + + + + + , + ); + + const emailInput = screen.getByPlaceholderText("Email"); + const passwordInput = screen.getByPlaceholderText("Password"); + const loginButton = screen.getByRole("button", { name: /login/i }); + + userEvent.type(emailInput, "invalid-email"); + userEvent.type(passwordInput, "short"); + userEvent.click(loginButton); + }); + + test("should handle successful login", async () => { + render( + + + + + + , + ); + + const emailInput = screen.getByPlaceholderText("Email"); + const passwordInput = screen.getByPlaceholderText("Password"); + const loginButton = screen.getByRole("button", { name: /login/i }); + userEvent.type(emailInput, "testuser@example.com"); + userEvent.type(passwordInput, "password123"); userEvent.click(loginButton); - expect(emailInput).toBeDefined(); - expect(passwordInput).toBeDefined(); - expect(localStorage.getItem("accessToken")).toBeDefined(); + expect(localStorage.getItem("accessToken")).toBe(null); + }); + + test("should handle failed login", async () => { + mock.onPost("/login").reply(401, { message: "Invalid credentials" }); + + render( + + + + + + , + ); + + const emailInput = screen.getByPlaceholderText("Email"); + const passwordInput = screen.getByPlaceholderText("Password"); + const loginButton = screen.getByRole("button", { name: /login/i }); + + userEvent.type(emailInput, "wronguser@example.com"); + userEvent.type(passwordInput, "wrongpassword"); + userEvent.click(loginButton); + expect(localStorage.getItem("accessToken")).toBe(null); + }); + + test("should navigate based on user role after login", async () => { + const mockAdminToken = "mockAdminToken"; + const mockSellerToken = "mockSellerToken"; + const mockCustomerToken = "mockCustomerToken"; + const mockAdminData = { message: "success", token: mockAdminToken }; + const mockSellerData = { message: "success", token: mockSellerToken }; + const mockCustomerData = { message: "success", token: mockCustomerToken }; + + mock.onPost("/login").reply((config) => { + if (config.data.includes("admin@example.com")) return [200, mockAdminData]; + if (config.data.includes("seller@example.com")) return [200, mockSellerData]; + return [200, mockCustomerData]; + }); + + render( + + + + + + , + ); + + // Test for admin role + userEvent.type(screen.getByPlaceholderText("Email"), "admin@example.com"); + userEvent.type(screen.getByPlaceholderText("Password"), "password123"); + userEvent.click(screen.getByRole("button", { name: /login/i })); + + expect(localStorage.getItem("accessToken")).toBe(null); + + // Test for seller role + userEvent.type(screen.getByPlaceholderText("Email"), "seller@example.com"); + userEvent.type(screen.getByPlaceholderText("Password"), "password123"); + userEvent.click(screen.getByRole("button", { name: /login/i })); + + expect(localStorage.getItem("accessToken")).toBe(null); + + // Test for customer role + userEvent.type( + screen.getByPlaceholderText("Email"), + "customer@example.com", + ); + userEvent.type(screen.getByPlaceholderText("Password"), "password123"); + userEvent.click(screen.getByRole("button", { name: /login/i })); + + expect(localStorage.getItem("accessToken")).toBe(null); }); it("should handle initial state", () => { @@ -90,7 +201,7 @@ describe("test user login", () => { }); it("should handle login fulfilled", () => { - const mockLoginData = { message: "Youre're logged in!" }; + const mockLoginData: any = { message: "You're logged in!" }; // @ts-ignore store.dispatch(login.fulfilled(mockLoginData, "", {})); expect(store.getState().login).toEqual({ diff --git a/src/__test__/logout.test.tsx b/src/__test__/logout.test.tsx new file mode 100644 index 0000000..23b3353 --- /dev/null +++ b/src/__test__/logout.test.tsx @@ -0,0 +1,83 @@ +import "@testing-library/jest-dom"; +import { + render, screen, fireEvent, waitFor, +} from "@testing-library/react"; +import { isExpired } from "react-jwt"; + +import { + useLogout, + LogoutProvider, +} from "../components/dashboard/admin/LogoutContext"; +import { performLogout } from "../utils/logoutUtils"; + +// Mocking performLogout and isExpired functions +jest.mock("../utils/logoutUtils", () => ({ + performLogout: jest.fn(), +})); +jest.mock("react-jwt", () => ({ + isExpired: jest.fn(), +})); + +const MockComponent = () => { + const { openLogoutModal } = useLogout(); + return ; +}; + +describe("LogoutContext", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should render children", () => { + render( + +
Child Component
+
, + ); + expect(screen.getByText("Child Component")).toBeInTheDocument(); + }); + + it("should open and close the logout modal", () => { + render( + + + , + ); + + fireEvent.click(screen.getByText("Open Logout Modal")); + expect(screen.getByText("Confirm Logout")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Cancel")); + expect(screen.queryByText("Confirm Logout")).not.toBeInTheDocument(); + }); + + it("should handle logout", async () => { + (performLogout as jest.Mock).mockResolvedValueOnce(undefined); + + render( + + + , + ); + + fireEvent.click(screen.getByText("Open Logout Modal")); + expect(screen.getByText("Confirm Logout")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Logout")); + await waitFor(() => expect(performLogout).toHaveBeenCalled()); + expect(screen.queryByText("Confirm Logout")).not.toBeInTheDocument(); + }); + + it("should not redirect if token is not expired", async () => { + (isExpired as jest.Mock).mockReturnValue(false); + + render( + + + , + ); + + await waitFor(() => expect(isExpired).toHaveBeenCalled()); + expect(window.location.href).not.toBe("/"); + }); +}); diff --git a/src/__test__/performLogout.test.tsx b/src/__test__/performLogout.test.tsx new file mode 100644 index 0000000..0df5bd7 --- /dev/null +++ b/src/__test__/performLogout.test.tsx @@ -0,0 +1,73 @@ +import "@testing-library/jest-dom"; +import { performLogout } from "../utils/logoutUtils"; +import api from "../redux/api/api"; + +// Mock the API and localStorage +jest.mock("../redux/api/api"); +const mockPost = api.post as jest.MockedFunction; + +describe("performLogout", () => { + let consoleErrorMock: jest.SpyInstance; + beforeAll(() => { + consoleErrorMock = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + }); + + afterAll(() => { + consoleErrorMock.mockRestore(); + }); + + beforeEach(() => { + localStorage.clear(); + mockPost.mockReset(); + consoleErrorMock.mockClear(); + }); + + it("should perform logout successfully", async () => { + localStorage.setItem("accessToken", "mockToken"); + mockPost.mockResolvedValue({}); + + await performLogout(); + + expect(mockPost).toHaveBeenCalledWith( + "/users/logout", + {}, + { + headers: { + Authorization: `Bearer mockToken`, + Accept: "*/*", + }, + }, + ); + expect(localStorage.getItem("accessToken")).toBeNull(); + expect(window.location.href.startsWith("http")).toBe(true); + }); + + it("should handle logout failure", async () => { + localStorage.setItem("accessToken", "mockToken"); + const error = new Error("Network error"); + mockPost.mockRejectedValue(error); + + await performLogout(); + + expect(mockPost).toHaveBeenCalledWith( + "/users/logout", + {}, + { + headers: { + Authorization: `Bearer mockToken`, + Accept: "*/*", + }, + }, + ); + expect(console.error).toHaveBeenCalledWith("Failed to logout:", error); + }); + + it("should not call API if accessToken is missing", async () => { + await performLogout(); + + expect(mockPost).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/common/ProfileDropdown.tsx b/src/components/common/ProfileDropdown.tsx index bc4322f..1f38906 100644 --- a/src/components/common/ProfileDropdown.tsx +++ b/src/components/common/ProfileDropdown.tsx @@ -1,64 +1,70 @@ import React from "react"; import { Link } from "react-router-dom"; +import { useLogout } from "../dashboard/admin/LogoutContext"; + interface ProfileDropdownProps { userInfo: any; } -const ProfileDropdown: React.FC = ({ userInfo }) => ( -
-
    - {userInfo ? ( - <> -
  • - - Profile - -
  • - {userInfo.roleId === 1 && ( +const ProfileDropdown: React.FC = ({ userInfo }) => { + const { openLogoutModal } = useLogout(); + + return ( +
    +
      + {userInfo ? ( + <>
    • - My Orders + Profile
    • - )} - {(userInfo.roleId === 2 || userInfo.roleId === 3) && ( + {userInfo.roleId === 1 && ( +
    • + + My Orders + +
    • + )} + {(userInfo.roleId === 2 || userInfo.roleId === 3) && ( +
    • + + My Dashboard + +
    • + )}
    • - - My Dashboard - + Logout +
    • - )} + + ) : (
    • - Logout + Login
    • - - ) : ( -
    • - - Login - -
    • - )} -
    -
    -); + )} +
+
+ ); +}; export default ProfileDropdown; diff --git a/src/components/dashboard/Header.tsx b/src/components/dashboard/Header.tsx index 48c8aef..1ee19b6 100644 --- a/src/components/dashboard/Header.tsx +++ b/src/components/dashboard/Header.tsx @@ -1,9 +1,10 @@ import { FiSearch, FiMenu } from "react-icons/fi"; -import { FaRegBell, FaCircle } from "react-icons/fa"; +import { FaRegBell, FaCircle, FaUserAlt } from "react-icons/fa"; import { FaAngleDown } from "react-icons/fa6"; import { BiSolidMessageDetail } from "react-icons/bi"; import React, { useEffect, useRef, useState } from "react"; import { useSelector } from "react-redux"; + import { getProfile } from "../../redux/reducers/profileSlice"; import { useAppDispatch, useAppSelector } from "../../redux/hooks"; import { RootState } from "../../redux/store"; @@ -25,6 +26,7 @@ const Header: React.FC = ({ toggleSidebar }) => { useEffect(() => { dispatch(getProfile()); }, [dispatch]); + const profileImage = profile?.profileImage; const userInfo = localStorage.getItem("accessToken") ? JSON.parse(atob(localStorage.getItem("accessToken")!.split(".")[1])) @@ -54,14 +56,12 @@ const Header: React.FC = ({ toggleSidebar }) => { <>
- -
- -
+ +
-
-
- +
+
+ = ({ toggleSidebar }) => {
setTarget(e.currentTarget)} > - -

+ +

{unreadCount}

- User Avatar setShowDropdown(!showDropdown)} - /> -

{profile?.fullName}

- setShowDropdown(!showDropdown)} /> - {showDropdown && } -
+ className="relative flex items-center h-full space-x-1 cursor-pointer" + ref={profileDropdownRef} + > + {profileImage ? ( + User Avatar setShowDropdown(!showDropdown)} + /> + ) : ( + setShowDropdown(!showDropdown)} + /> + )} +

{profile?.fullName}

+ setShowDropdown(!showDropdown)} + /> + {showDropdown && } +
diff --git a/src/components/dashboard/HomeButton.tsx b/src/components/dashboard/HomeButton.tsx new file mode 100644 index 0000000..ccc2b31 --- /dev/null +++ b/src/components/dashboard/HomeButton.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { FiLogOut } from "react-icons/fi"; +import { useNavigate } from "react-router-dom"; + +const HomeButton: React.FC = () => { + const navigate = useNavigate(); + + const handleRedirect = () => { + navigate("/"); + }; + + return ( + + ); +}; + +export default HomeButton; diff --git a/src/components/dashboard/SideBar.tsx b/src/components/dashboard/SideBar.tsx index e9e7bca..7110c52 100644 --- a/src/components/dashboard/SideBar.tsx +++ b/src/components/dashboard/SideBar.tsx @@ -3,10 +3,11 @@ import { RiHome3Line, RiAddBoxLine } from "react-icons/ri"; import { IoBriefcaseOutline, IoSettingsOutline } from "react-icons/io5"; import { AiFillProduct } from "react-icons/ai"; import { MdInsertChartOutlined } from "react-icons/md"; -import { FiLogOut } from "react-icons/fi"; import { Link, NavLink, useLocation } from "react-router-dom"; import { FaCircle } from "react-icons/fa"; +import HomeButton from "./HomeButton"; + interface SidebarProps { isOpen: boolean; } @@ -25,15 +26,15 @@ const SideBar: React.FC = ({ isOpen }) => { isOpen ? "translate-x-0" : "-translate-x-full" } lg:translate-x-0`} > -
-
+
+
-
+
eagles
-
-
- - Log Out -
+
); diff --git a/src/components/dashboard/admin/AdminSideBar.tsx b/src/components/dashboard/admin/AdminSideBar.tsx index 1a17353..8521e34 100644 --- a/src/components/dashboard/admin/AdminSideBar.tsx +++ b/src/components/dashboard/admin/AdminSideBar.tsx @@ -1,14 +1,14 @@ -// AdminSideBar.tsx import React from "react"; import { RiHome3Line } from "react-icons/ri"; import { TbUsers } from "react-icons/tb"; import { AiFillProduct } from "react-icons/ai"; import { SiSimpleanalytics } from "react-icons/si"; import { IoSettingsOutline } from "react-icons/io5"; -import { FiLogOut } from "react-icons/fi"; import { FaCircle } from "react-icons/fa"; import { useLocation } from "react-router-dom"; +import HomeButton from "../HomeButton"; + import NavItem from "./NavItem"; interface SidebarProps { @@ -29,11 +29,6 @@ const AdminSideBar: React.FC = ({ isOpen }) => { icon: , label: "Users", }, - { - to: "/admin/products", - icon: , - label: "Products", - }, { to: "/admin/analytics", icon: , @@ -48,11 +43,9 @@ const AdminSideBar: React.FC = ({ isOpen }) => { return (
@@ -73,10 +66,7 @@ const AdminSideBar: React.FC = ({ isOpen }) => { ))}
-
- - Log Out -
+
); diff --git a/src/components/dashboard/admin/LogoutContext.tsx b/src/components/dashboard/admin/LogoutContext.tsx new file mode 100644 index 0000000..967c759 --- /dev/null +++ b/src/components/dashboard/admin/LogoutContext.tsx @@ -0,0 +1,78 @@ +import React, { + createContext, + useContext, + useState, + ReactNode, + useMemo, + useEffect, +} from "react"; +import { isExpired } from "react-jwt"; + +import { performLogout } from "../../../utils/logoutUtils"; + +import LogoutModal from "./LogoutModal"; + +interface LogoutContextType { + openLogoutModal: () => void; +} + +const LogoutContext = createContext(undefined); + +export const useLogout = () => { + const context = useContext(LogoutContext); + if (!context) { + throw new Error("useLogout must be used within a LogoutProvider"); + } + return context; +}; + +export const LogoutProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + const openLogoutModal = () => { + setIsModalOpen(true); + }; + + const closeLogoutModal = () => { + setIsModalOpen(false); + }; + + const handleLogout = async () => { + await performLogout(); + closeLogoutModal(); + }; + + const value = useMemo(() => ({ openLogoutModal }), []); + + useEffect(() => { + const checkTokenExpiration = async () => { + const accessToken: any = localStorage.getItem("accessToken"); + + console.log(isExpired(accessToken)); + if (accessToken && isExpired(accessToken)) { + try { + localStorage.removeItem("accessToken"); + // eslint-disable-next-line no-restricted-globals + window.location.href = "/"; + } catch (error) { + console.error("Failed to logout:", error); + } + } + }; + + checkTokenExpiration(); + }, []); + + return ( + + {children} + + + ); +}; diff --git a/src/components/dashboard/admin/LogoutModal.tsx b/src/components/dashboard/admin/LogoutModal.tsx new file mode 100644 index 0000000..1032d02 --- /dev/null +++ b/src/components/dashboard/admin/LogoutModal.tsx @@ -0,0 +1,53 @@ +import React, { useState } from "react"; + +const LogoutModal: React.FC<{ + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; +}> = ({ isOpen, onClose, onConfirm }) => { + const [isLoading, setIsLoading] = useState(false); + + const handleConfirm = async () => { + setIsLoading(true); + try { + await onConfirm(); + } finally { + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +

Confirm Logout

+

Are you sure you want to logout?

+
+ + +
+
+
+ ); +}; + +export default LogoutModal; diff --git a/src/dashboard/admin/Products.tsx b/src/dashboard/admin/Products.tsx index 70315f4..3a6bc08 100644 --- a/src/dashboard/admin/Products.tsx +++ b/src/dashboard/admin/Products.tsx @@ -2,9 +2,9 @@ import React from "react"; import AdminLayout from "../../components/layouts/AdminLayout"; -const Products = (props) => ( +const ProductsForAdmin = (props) => (
Products page
); -export default Products; +export default ProductsForAdmin; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index dc68a61..36565e5 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -74,9 +74,30 @@ const Login = () => { }; const token = getTokenFromUrl(); + if (token) { localStorage.setItem("accessToken", token); - navigate("/"); + + try { + const decodedToken = decodeToken(token); + // @ts-ignore + const roleId = decodedToken?.roleId; + + if (roleId === 3) { + navigate("/admin/dashboard"); + } else if (roleId === 2) { + navigate("/dashboard"); + } else if (roleId === 1) { + navigate("/"); + } else { + navigate("/login"); + } + } catch (error) { + console.error("Error decoding token:", error); + navigate("/login"); + } + } else { + navigate("/login"); } }, [navigate]); @@ -88,7 +109,7 @@ const Login = () => {
-

+

Login to diff --git a/src/redux/ProtectAdminDashboard.tsx b/src/redux/ProtectAdminDashboard.tsx index baca0ed..2d57eb0 100644 --- a/src/redux/ProtectAdminDashboard.tsx +++ b/src/redux/ProtectAdminDashboard.tsx @@ -2,36 +2,46 @@ import React, { useEffect, useState } from "react"; import { Navigate } from "react-router-dom"; import { decodeToken, isExpired } from "react-jwt"; -interface ProtectDashboardProps { +import { performLogout } from "../utils/logoutUtils"; + +interface ProtectAdminDashboardProps { children: JSX.Element; } -const ProtectAdminDashboard: React.FC = ({ +const ProtectAdminDashboard: React.FC = ({ children, }) => { const [isAuthorized, setIsAuthorized] = useState(true); useEffect(() => { - const accessToken = localStorage.getItem("accessToken"); - - if (!accessToken) { - setIsAuthorized(false); - return; - } + const checkAuthorization = async () => { + const accessToken = localStorage.getItem("accessToken"); - try { - const decodedToken = decodeToken(accessToken); - const isTokenExpired = isExpired(accessToken); + if (!accessToken) { + setIsAuthorized(false); + return; + } - // @ts-ignore - if (!decodedToken || isTokenExpired || decodedToken.roleId !== 3) { + try { + const decodedToken = decodeToken(accessToken); + const isTokenExpired = isExpired(accessToken); + console.log(isExpired); + // @ts-ignore + if (!decodedToken || isTokenExpired || decodedToken.roleId !== 3) { + if (isTokenExpired) { + await performLogout(); + } + setIsAuthorized(false); + } else { + setIsAuthorized(true); + } + } catch (error) { + await performLogout(); setIsAuthorized(false); - } else { - setIsAuthorized(true); } - } catch (error) { - setIsAuthorized(false); - } + }; + + checkAuthorization(); }, []); if (!isAuthorized) { diff --git a/src/redux/ProtectDashboard.tsx b/src/redux/ProtectDashboard.tsx index 3160fd8..5fd9f0a 100644 --- a/src/redux/ProtectDashboard.tsx +++ b/src/redux/ProtectDashboard.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from "react"; import { Navigate } from "react-router-dom"; import { decodeToken, isExpired } from "react-jwt"; +import { performLogout } from "../utils/logoutUtils"; + interface ProtectDashboardProps { children: JSX.Element; } @@ -10,26 +12,33 @@ const ProtectDashboard: React.FC = ({ children }) => { const [isAuthorized, setIsAuthorized] = useState(true); useEffect(() => { - const accessToken = localStorage.getItem("accessToken"); - - if (!accessToken) { - setIsAuthorized(false); - return; - } + const checkAuthorization = async () => { + const accessToken = localStorage.getItem("accessToken"); - try { - const decodedToken = decodeToken(accessToken); - const isTokenExpired = isExpired(accessToken); + if (!accessToken) { + setIsAuthorized(false); + return; + } - // @ts-ignore - if (!decodedToken || isTokenExpired || decodedToken.roleId !== 2) { + try { + const decodedToken = decodeToken(accessToken); + const isTokenExpired = isExpired(accessToken); + // @ts-ignore + if (!decodedToken || isTokenExpired || decodedToken.roleId !== 2) { + if (isTokenExpired) { + await performLogout(); // Call the logout function + } + setIsAuthorized(false); + } else { + setIsAuthorized(true); + } + } catch (error) { + await performLogout(); // Handle any errors by logging out setIsAuthorized(false); - } else { - setIsAuthorized(true); } - } catch (error) { - setIsAuthorized(false); - } + }; + + checkAuthorization(); }, []); if (!isAuthorized) { diff --git a/src/redux/api/api.ts b/src/redux/api/api.ts index 0da8ef6..5bdf85c 100644 --- a/src/redux/api/api.ts +++ b/src/redux/api/api.ts @@ -14,11 +14,17 @@ api.interceptors.response.use( (response) => response, (error) => { const excludeRoute = "/"; + const currentPath = window.location.pathname; + const pathParts = currentPath.split("/"); + const isSingleProductView = pathParts.length === 3 + && pathParts[1] === "products" + && !Number.isNaN(Number(pathParts[2])); if ( !isNotificationSent && error.response && error.response.status === 401 && window.location.pathname !== excludeRoute + && !isSingleProductView ) { isNotificationSent = true; if (navigateFunction) { diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 7d68147..6829c49 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -34,6 +34,7 @@ import SignupVerification from "../pages/SignupVerification"; import SmoothScroll from "../utils/SmoothScroll"; import NotFound from "../pages/NotFound"; import Payment, { SuccessfulPayment } from "../pages/paymentPage"; +import { LogoutProvider } from "../components/dashboard/admin/LogoutContext"; const AppRoutes = () => { const navigate = useNavigate(); @@ -60,57 +61,59 @@ const AppRoutes = () => { return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - } /> + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> - - - - )} - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } - /> - } /> - } /> - + + + + )} + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } /> + } /> + + ); }; diff --git a/src/utils/logoutUtils.ts b/src/utils/logoutUtils.ts new file mode 100644 index 0000000..25cec12 --- /dev/null +++ b/src/utils/logoutUtils.ts @@ -0,0 +1,24 @@ +import api from "../redux/api/api"; + +export const performLogout = async () => { + try { + const accessToken = localStorage.getItem("accessToken"); + if (accessToken) { + await api.post( + "/users/logout", + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "*/*", + }, + }, + ); + localStorage.removeItem("accessToken"); + // eslint-disable-next-line no-restricted-globals + window.location.href = "/"; + } + } catch (error) { + console.error("Failed to logout:", error); + } +};