From b629c350c30a486d72269d8d5fa7b4ca3f80e96d Mon Sep 17 00:00:00 2001 From: "hanan o." <77591083+heosman@users.noreply.github.com> Date: Sun, 21 Jul 2024 20:53:23 -0700 Subject: [PATCH] belindas-closet-nextjs_10_504_implement-role-based-authorization (#505) * Restricted Add Product page to admins & creators * Restricted Archived Products page to admins & creators * Restricted Creator page to creators * Restricted Admin page to admins * Restricted Edit User Role page to admins * Updated tests to fix errors and improve coverage * Restricted Dashboard page to admins & creators I also updated the dashboard page tests --- app/add-product-page/page.tsx | 370 +++++++++++----------- app/admin-page/page.tsx | 91 +++--- app/archived-products-page/page.tsx | 80 +++-- app/creator-page/page.tsx | 85 +++-- app/dashboard/page.tsx | 52 +-- app/edit-user-role-page/page.tsx | 31 +- components/UnauthorizedPageMessage.tsx | 16 + tests/unit/add-product-page/page.test.tsx | 67 ++-- tests/unit/admin-page/admin.test.tsx | 43 ++- tests/unit/creator-page/page.test.tsx | 80 +++-- tests/unit/dashboard-page/page.test.tsx | 36 ++- 11 files changed, 578 insertions(+), 373 deletions(-) create mode 100644 components/UnauthorizedPageMessage.tsx diff --git a/app/add-product-page/page.tsx b/app/add-product-page/page.tsx index 2000a894..dec3fcfb 100644 --- a/app/add-product-page/page.tsx +++ b/app/add-product-page/page.tsx @@ -21,6 +21,7 @@ import { ProductSizePantsWaistList, ProductSizePantsInseamList, } from "./product-prop-list"; +import UnauthorizedPageMessage from "@/components/UnauthorizedPageMessage"; // WARNING: You won't be able to connect to local backend unless you remove the env variable below. const URL = process.env.BELINDAS_CLOSET_PUBLIC_API_URL || "http://localhost:3000/api"; @@ -148,202 +149,215 @@ const AddProduct = () => { const notSizeApplicable = ["", "Pants", "Shoes"]; - return ( -
- - - Add a Product - + const [userRole, setUserRole] = useState(""); + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + const userRole = JSON.parse(atob(token.split(".")[1])).role; + setUserRole(userRole); + } + }, []); - {/* Product Type Field */} - - Product Type - - + Add a Product + - {/* Product Gender Field */} - {productType == "" ? null : ( + {/* Product Type Field */} - Product Gender + Product Type - )} - {/* Product Size Shoe Field */} - {productType != "Shoes" ? null : ( - - Shoe Size - - - )} + {/* Product Gender Field */} + {productType == "" ? null : ( + + Product Gender + + + )} - {/* Product Size Field */} - {notSizeApplicable.includes(productType) ? null : ( - - Product Size - - - )} + {/* Product Size Shoe Field */} + {productType != "Shoes" ? null : ( + + Shoe Size + + + )} - {/* Product Size Pants Waist Field */} - {productType != "Pants" ? null : ( - - Waist Size - - - )} + {/* Product Size Field */} + {notSizeApplicable.includes(productType) ? null : ( + + Product Size + + + )} - {/* Product Size Pants Inseam Field */} - {productType != "Pants" ? null : ( - - Inseam Length - - - )} + {/* Product Size Pants Waist Field */} + {productType != "Pants" ? null : ( + + Waist Size + + + )} - {/* Product Description Field */} - {productType == "" ? null : ( - - - - )} + {/* Product Size Pants Inseam Field */} + {productType != "Pants" ? null : ( + + Inseam Length + + + )} - {/* Product Upload Image Field */} - {productType == "" ? null : ( - - {previewUrl && ( - logo - )} - - - )} + + )} - {/* Submit Button */} - {productType == "" ? null : ( - - )} - -
- ); + {/* Product Upload Image Field */} + {productType == "" ? null : ( + + {previewUrl && ( + logo + )} + + + )} + + {/* Submit Button */} + {productType == "" ? null : ( + + )} + + + ); + } else { + return ; + } }; export default AddProduct; diff --git a/app/admin-page/page.tsx b/app/admin-page/page.tsx index e0bc6777..e052933a 100644 --- a/app/admin-page/page.tsx +++ b/app/admin-page/page.tsx @@ -4,49 +4,64 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import ButtonGroup from '@mui/material/ButtonGroup' import { useMediaQuery, useTheme } from '@mui/material'; +import UnauthorizedPageMessage from '@/components/UnauthorizedPageMessage'; +import { useState, useEffect } from 'react'; const Admin = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - return ( -
- - My Account - - - - - - - -
- ); + const [userRole, setUserRole] = useState(""); + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + const userRole = JSON.parse(atob(token.split(".")[1])).role; + setUserRole(userRole); + } + }, []); + + if ((userRole === "admin")) { + return ( +
+ + My Account + + + + + + + +
+ ); + } else { + return + } }; export default Admin; diff --git a/app/archived-products-page/page.tsx b/app/archived-products-page/page.tsx index 388ac278..7dec1d2e 100644 --- a/app/archived-products-page/page.tsx +++ b/app/archived-products-page/page.tsx @@ -4,6 +4,7 @@ import React, { useState, useEffect, Dispatch, SetStateAction } from "react"; import ProductCard from "@/components/ProductCard"; import logo from "@/public/belinda-images/logo.png"; import { Container, Grid, Typography } from "@mui/material"; +import UnauthorizedPageMessage from "@/components/UnauthorizedPageMessage"; // WARNING: You won't be able to connect to local backend unless you remove the env variable below. const URL = process.env.BELINDAS_CLOSET_PUBLIC_API_URL || "http://localhost:3000/api"; const placeholderImg = logo; @@ -62,39 +63,52 @@ const ViewProduct = ({ categoryId }: { categoryId: string }) => { ); }, [products]); - return ( - - - Found {filteredProducts.length} products in Archived Products - - - {filteredProducts.map((product, index) => ( - - - - ))} - - - ); + const [userRole, setUserRole] = useState(""); + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + const userRole = JSON.parse(atob(token.split(".")[1])).role; + setUserRole(userRole); + } + }, []); + + if ((userRole === "admin" || userRole === "creator")) { + return ( + + + Found {filteredProducts.length} products in Archived Products + + + {filteredProducts.map((product, index) => ( + + + + ))} + + + ); + } else { + return ; + } }; export default function ProductList({ params, diff --git a/app/creator-page/page.tsx b/app/creator-page/page.tsx index 78866233..1955e37e 100644 --- a/app/creator-page/page.tsx +++ b/app/creator-page/page.tsx @@ -4,46 +4,61 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; import ButtonGroup from '@mui/material/ButtonGroup'; import { useMediaQuery, useTheme } from '@mui/material'; +import UnauthorizedPageMessage from '@/components/UnauthorizedPageMessage'; +import { useState, useEffect } from 'react'; const Creator = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - return ( -
- - My Account - - - - - - -
- ); + const [userRole, setUserRole] = useState(""); + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + const userRole = JSON.parse(atob(token.split(".")[1])).role; + setUserRole(userRole); + } + }, []); + + if ((userRole === "creator")) { + return ( +
+ + My Account + + + + + + +
+ ); + } else { + return ; + } }; export default Creator; diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 1704d9e2..6e24fe33 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,7 +1,8 @@ "use client"; import { Typography, Drawer, List, ListItem, ListItemText, IconButton } from "@mui/material"; -import { SetStateAction, useState } from "react"; +import { SetStateAction, useEffect, useState } from "react"; import MenuIcon from '@mui/icons-material/Menu'; +import UnauthorizedPageMessage from "@/components/UnauthorizedPageMessage"; const Dashboard = () => { const [drawerOpen, setDrawerOpen] = useState(false); @@ -53,24 +54,37 @@ const Dashboard = () => { ); - return ( -
- - {drawerContent} - - - {drawerOpen ? : } - - - Dashboard - - {/* Add your dashboard content here */} -
- ); + const [userRole, setUserRole] = useState(""); + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + const userRole = JSON.parse(atob(token.split(".")[1])).role; + setUserRole(userRole); + } + }, []); + + if ((userRole === "admin" || userRole === "creator")) { + return ( +
+ + {drawerContent} + + + {drawerOpen ? : } + + + Dashboard + + {/* Add your dashboard content here */} +
+ ); + } else { + return ; + }; }; export default Dashboard; \ No newline at end of file diff --git a/app/edit-user-role-page/page.tsx b/app/edit-user-role-page/page.tsx index d51852de..43292a9c 100644 --- a/app/edit-user-role-page/page.tsx +++ b/app/edit-user-role-page/page.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import UserCard from "../../components/UserCard"; import { Stack, Typography } from "@mui/material"; +import UnauthorizedPageMessage from "@/components/UnauthorizedPageMessage"; // WARNING: You won't be able to connect to local backend unless you remove the env variable below. const URL = process.env.BELINDAS_CLOSET_PUBLIC_API_URL || "http://localhost:3000/api"; @@ -53,22 +54,32 @@ async function fetchUser(setUserInfo: (userInfo: User[]) => void, userToken: JWT */ const EditUserRolePage = () => { const [userInfo, setUserInfo] = useState([]); + const [userRole, setUserRole] = useState(""); useEffect(() => { const userToken:JWToken = localStorage.getItem("token") + const token = localStorage.getItem("token"); + if (token) { + const userRole = JSON.parse(atob(token.split(".")[1])).role; + setUserRole(userRole); + } fetchUser(setUserInfo,userToken); }, []); - return ( - - - User Management - - {userInfo.map((user, index) => ( - - ))} - - ); + if ((userRole === "admin")) { + return ( + + + User Management + + {userInfo.map((user, index) => ( + + ))} + + ); + } else { + return + } }; export default EditUserRolePage; diff --git a/components/UnauthorizedPageMessage.tsx b/components/UnauthorizedPageMessage.tsx new file mode 100644 index 00000000..11c731a8 --- /dev/null +++ b/components/UnauthorizedPageMessage.tsx @@ -0,0 +1,16 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; + +const UnauthorizedPageMessage: React.FC = () => { + return ( + + + 401 Unauthorized + + + You are not authorized to access this page + + + ); +} +export default UnauthorizedPageMessage; diff --git a/tests/unit/add-product-page/page.test.tsx b/tests/unit/add-product-page/page.test.tsx index 60ebbb0f..4a8705f7 100644 --- a/tests/unit/add-product-page/page.test.tsx +++ b/tests/unit/add-product-page/page.test.tsx @@ -1,32 +1,59 @@ import AddProduct from '@/app/add-product-page/page' import { ProductTypeList } from '@/app/add-product-page/product-prop-list' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' +const roles = ['admin', 'creator', 'user']; -describe('add-product-page tests', () => { - // Arrange - beforeEach(() => { +describe.each(roles)('add-product-page tests for role: %s', (role) => { + beforeEach(() => { render() }) - it('contains title', () => { - // Act - const title = screen.getByText(/Add a Product/i ) - // Assert - expect(title).toBeInTheDocument() - - }) + beforeAll(() => { + const mockToken = btoa(JSON.stringify({ role })); + localStorage.setItem('token', `fakeHeader.${mockToken}.fakeSignature`); + }); + + afterAll(() => { + localStorage.removeItem('token'); + }); - it('checks if each product type option are rendered after clicking', () => { - // Act - const selectType = screen.getByLabelText(/Product Type/i) - fireEvent.mouseDown(selectType) + if (role === 'user') { + it('displays UnauthorizedPageMessage for user role', async () => { + await waitFor(() => { + expect(screen.getByText(/401 Unauthorized/i)).toBeInTheDocument(); + }); - // Assert - Object.values(ProductTypeList).forEach((type) => { - const option = screen.getByRole('option', { name: new RegExp(type, 'i') }) - expect(option).toBeInTheDocument() + const unauthorizedMessage = screen.getByText(/You are not authorized to access this page/i); + expect(unauthorizedMessage).toBeInTheDocument(); + }); + } else { + it('contains title', async () => { + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + // Act + const title = screen.getByText(/Add a Product/i ) + + // Assert + expect(title).toBeInTheDocument() + }) - }) + + it('checks if each product type option are rendered after clicking', async () => { + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + // Act + const selectType = screen.getByLabelText(/Product Type/i) + fireEvent.mouseDown(selectType) + + // Assert + Object.values(ProductTypeList).forEach((type) => { + const option = screen.getByRole('option', { name: new RegExp(type, 'i') }) + expect(option).toBeInTheDocument() + }) + }) + } }) \ No newline at end of file diff --git a/tests/unit/admin-page/admin.test.tsx b/tests/unit/admin-page/admin.test.tsx index bec2e4db..75691f1a 100644 --- a/tests/unit/admin-page/admin.test.tsx +++ b/tests/unit/admin-page/admin.test.tsx @@ -1,12 +1,41 @@ import '@testing-library/jest-dom' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import Admin from '../../../app/admin-page/page'; -describe('Admin', () => { - it('renders the buttons', () => { - render(); - const links = screen.getAllByRole('link'); // Update to search for links - const buttonText = screen.getByText('Edit User Roles'); - expect(links && buttonText).toBeInTheDocument(); +const roles = ['admin', 'creator', 'user']; + +describe.each(roles)('admin-page tests for role: %s', (role) => { + beforeEach(() => { + render() + }) + + beforeAll(() => { + const mockToken = btoa(JSON.stringify({ role })); + localStorage.setItem('token', `fakeHeader.${mockToken}.fakeSignature`); + }); + + afterAll(() => { + localStorage.removeItem('token'); }); + + if (role === 'creator' || role === 'user') { + it('displays UnauthorizedPageMessage for unauthorized role', async () => { + await waitFor(() => { + expect(screen.getByText(/401 Unauthorized/i)).toBeInTheDocument(); + }); + + const unauthorizedMessage = screen.getByText(/You are not authorized to access this page/i); + expect(unauthorizedMessage).toBeInTheDocument(); + }); + } else { + it('renders the buttons', async () => { + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + + const links = screen.getAllByRole('link'); // Update to search for links + const buttonText = screen.getByText('Edit User Roles'); + expect(links && buttonText).toBeInTheDocument(); + }); + }; }); \ No newline at end of file diff --git a/tests/unit/creator-page/page.test.tsx b/tests/unit/creator-page/page.test.tsx index f9df60f7..3c4aacfa 100644 --- a/tests/unit/creator-page/page.test.tsx +++ b/tests/unit/creator-page/page.test.tsx @@ -1,34 +1,62 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import Creator from '@/app/creator-page/page'; -describe('Creator component tests', () => { - it('displays the placeholder text and interacts with buttons', async () => { - render(); - - // Check for the heading text - const heading = screen.getByRole('heading', { level: 1 }); - expect(heading).toBeInTheDocument(); - expect(heading.textContent).toBe('My Account'); - - // Check for the links and their href attributes - const addButton = screen.getByRole('link', { name: /Add Product/i }); - const allProductsButton = screen.getByRole('link', { name: /All Products/i }); - const archivedProductsButton = screen.getByRole('link', { name: /Archived Products/i }) - - expect(addButton).toBeInTheDocument(); - expect(addButton).toHaveAttribute('href', '/add-product-page'); - - expect(allProductsButton).toBeInTheDocument(); - expect(allProductsButton).toHaveAttribute('href', '/category-page/all-products'); +const roles = ['admin', 'creator', 'user']; - expect(archivedProductsButton).toBeInTheDocument(); - expect(archivedProductsButton).toHaveAttribute('href', '/archived-products-page'); +describe.each(roles)('creator-page tests for role: %s', (role) => { + beforeEach(() => { + render() + }) + + beforeAll(() => { + const mockToken = btoa(JSON.stringify({ role })); + localStorage.setItem('token', `fakeHeader.${mockToken}.fakeSignature`); + }); - // Simulate link clicks - await userEvent.click(addButton); - await userEvent.click(allProductsButton); - await userEvent.click(archivedProductsButton); + afterAll(() => { + localStorage.removeItem('token'); }); + + if (role === 'admin' || role === 'user') { + it('displays UnauthorizedPageMessage for unauthorized role', async () => { + await waitFor(() => { + expect(screen.getByText(/401 Unauthorized/i)).toBeInTheDocument(); + }); + + const unauthorizedMessage = screen.getByText(/You are not authorized to access this page/i); + expect(unauthorizedMessage).toBeInTheDocument(); + }); + } else { + it('displays the placeholder text and interacts with buttons', async () => { + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + + // Check for the heading text + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toBeInTheDocument(); + expect(heading.textContent).toBe('My Account'); + + // Check for the links and their href attributes + const addButton = screen.getByRole('link', { name: /Add Product/i }); + const allProductsButton = screen.getByRole('link', { name: /All Products/i }); + const archivedProductsButton = screen.getByRole('link', { name: /Archived Products/i }) + + expect(addButton).toBeInTheDocument(); + expect(addButton).toHaveAttribute('href', '/add-product-page'); + + expect(allProductsButton).toBeInTheDocument(); + expect(allProductsButton).toHaveAttribute('href', '/category-page/all-products'); + + expect(archivedProductsButton).toBeInTheDocument(); + expect(archivedProductsButton).toHaveAttribute('href', '/archived-products-page'); + + // Simulate link clicks + await userEvent.click(addButton); + await userEvent.click(allProductsButton); + await userEvent.click(archivedProductsButton); + }); + }; }); \ No newline at end of file diff --git a/tests/unit/dashboard-page/page.test.tsx b/tests/unit/dashboard-page/page.test.tsx index 82d66729..f6b53dc3 100644 --- a/tests/unit/dashboard-page/page.test.tsx +++ b/tests/unit/dashboard-page/page.test.tsx @@ -1,16 +1,38 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import Dashboard from '@/app/dashboard/page'; -describe('Dashboard component tests', () => { - it('renders heading with correct text and style', () => { - render(); +const roles = ['admin', 'creator', 'user']; - const heading = screen.getByRole('heading', { level: 1 }); +describe.each(roles)('dashboard-page tests for role: %s', (role) => { + beforeEach(() => { + render() + }) - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent('Dashboard'); + beforeAll(() => { + const mockToken = btoa(JSON.stringify({ role })); + localStorage.setItem('token', `fakeHeader.${mockToken}.fakeSignature`); }); + afterAll(() => { + localStorage.removeItem('token'); + }); + + if (role === 'user') { + it('displays UnauthorizedPageMessage for user role', async () => { + await waitFor(() => { + expect(screen.getByText(/401 Unauthorized/i)).toBeInTheDocument(); + }); + + const unauthorizedMessage = screen.getByText(/You are not authorized to access this page/i); + expect(unauthorizedMessage).toBeInTheDocument(); + }); + } else { + it('renders heading with correct text and style', () => { + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveTextContent('Dashboard'); + }); + }; }); \ No newline at end of file