From 507fcef73d3af7fc162a7df52f966c07ff0dbeaf Mon Sep 17 00:00:00 2001 From: Kea-Roy Ong <146872846+kea-roy@users.noreply.github.com> Date: Sat, 5 Oct 2024 14:05:31 -0400 Subject: [PATCH 01/10] secured current delete-review endpoint and updated update-review-status - this endpoint is not used, just added authentication to make sure api is robust. - adjusted authentication checks in update-review-status for security. - improved documentation on permissions --- backend/src/app.ts | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 27bca15b..1e45ca4a 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -490,8 +490,21 @@ app.post('/api/add-like', authenticate, likeHandler(false)); app.post('/api/remove-like', authenticate, likeHandler(true)); // Endpoint to delete a review by its document ID -app.put('/api/delete-review/:reviewId', async (req, res) => { +app.put('/api/delete-review/:reviewId', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); const { reviewId } = req.params; // Extract the review document ID from the request parameters + const { uid, email } = req.user; + // Check if the user is an admin or the creator of the review + const reviewDoc = reviewCollection.doc(reviewId); + const reviewData = (await reviewDoc.get()).data(); + if (!reviewData) { + res.status(404).send('Review not found'); + return; + } + if (reviewData?.userId !== uid && !(email && admins.includes(email))) { + res.status(403).send('Unauthorized'); + return; + } try { // Update the status of the review document to 'DELETED' await reviewCollection.doc(reviewId).update({ status: 'DELETED' }); @@ -706,8 +719,9 @@ app.post('/api/remove-saved-landlord', authenticate, saveLandlordHandler(false)) * Sends an email to the user if the review is approved. * * Permissions: - * User must be an admin to update a review to approved, declined, or deleted - * However, all users can update a review from approved to pending + * - An admin can update a review from any status to any status + * - A regular user can only update their own reviews from any status to deleted + * - A regular user cannot update other users' reviews * * @param reviewDocId - The document ID of the review to update * @param newStatus - The new status to set for the review @@ -722,11 +736,7 @@ app.put('/api/update-review-status/:reviewDocId/:newStatus', authenticate, async if (!req.user) throw new Error('Not authenticated'); const { reviewDocId, newStatus } = req.params; // Extracting parameters from the URL const { uid, email } = req.user; - // Checking if the user is authorized to update the review's status - if (newStatus !== 'PENDING' && !(email && admins.includes(email))) { - res.status(403).send('Unauthorized'); - return; - } + const isAdmin = email && admins.includes(email); const statusList = ['PENDING', 'APPROVED', 'DECLINED', 'DELETED']; try { // Validating if the new status is within the allowed list @@ -735,7 +745,14 @@ app.put('/api/update-review-status/:reviewDocId/:newStatus', authenticate, async return; } const reviewDoc = reviewCollection.doc(reviewDocId); - const currentStatus = (await reviewDoc.get()).data()?.status || ''; + const reviewData = (await reviewDoc.get()).data(); + const currentStatus = reviewData?.status || ''; + const reviewOwnerId = reviewData?.userId || ''; + // Check if user is authorized to change this review's status + if (!isAdmin && (uid !== reviewOwnerId || newStatus !== 'DELETED')) { + res.status(403).send('Unauthorized'); + return; + } // Updating the review's status in Firestore await reviewDoc.update({ status: newStatus }); res.status(200).send('Success'); // Sending a success response From e0fdaa99412c2d85dbdc72bc484ec651b8c3936d Mon Sep 17 00:00:00 2001 From: Kea-Roy Ong <146872846+kea-roy@users.noreply.github.com> Date: Sun, 6 Oct 2024 00:51:28 -0400 Subject: [PATCH 02/10] implement dialog and connect to back-end - create temporary delete modal for testing - create option menu temporary component --- frontend/src/components/Review/Review.tsx | 122 +++++++++++++++++++++- 1 file changed, 117 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Review/Review.tsx b/frontend/src/components/Review/Review.tsx index 70fc2184..2f33386f 100644 --- a/frontend/src/components/Review/Review.tsx +++ b/frontend/src/components/Review/Review.tsx @@ -12,12 +12,25 @@ import { Collapse, Link, useMediaQuery, + Menu, + MenuItem, + ListItemIcon, + ListItemText, } from '@material-ui/core'; import HeartRating from '../utils/HeartRating'; import { format } from 'date-fns'; import { makeStyles } from '@material-ui/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import MoreVertIcon from '@material-ui/icons/MoreVert'; +import EditIcon from '@material-ui/icons/Edit'; +import DeleteIcon from '@material-ui/icons/Delete'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogActions, + DialogContentText, +} from '@material-ui/core'; import clsx from 'clsx'; import { DetailedRating, @@ -148,6 +161,7 @@ const ReviewComponent = ({ const [apt, setApt] = useState([]); const [landlordData, setLandlordData] = useState(); const [reviewOpen, setReviewOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); const isMobile = useMediaQuery('(max-width:600px)'); const toastTime = 3500; @@ -161,6 +175,42 @@ const ReviewComponent = ({ }); if (triggerEditToast) triggerEditToast(); }; + const deleteModal = () => { + return ( + { + handleDeleteModalClose(false); + }} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + {'Delete this review?'} + + + You will not be able to recover deleted reviews. + + + + + + + + ); + }; const Modals = ( <> @@ -176,6 +226,7 @@ const ReviewComponent = ({ user={user} initialReview={reviewData} /> + {deleteModal()} ); const handleExpandClick = () => { @@ -209,6 +260,13 @@ const ReviewComponent = ({ setReviewOpen(true); }; + const openDeleteModal = async () => { + if (!user) { + return; + } + setDeleteModalOpen(true); + }; + const reportAbuseHandler = async (reviewId: string) => { const endpoint = `/api/update-review-status/${reviewData.id}/PENDING`; if (user) { @@ -230,6 +288,21 @@ const ReviewComponent = ({ } }; + const handleDeleteModalClose = async (deleteit: Boolean) => { + if (deleteit) { + const endpoint = `/api/update-review-status/${reviewData.id}/DELETED`; + if (user) { + const token = await user.getIdToken(true); + await axios.put(endpoint, {}, createAuthHeaders(token)); + setToggle((cur) => !cur); + } else { + let user = await getUser(true); + setUser(user); + } + } + setDeleteModalOpen(false); + }; + const handleLinkClick = () => { window.scrollTo({ top: 0, @@ -308,6 +381,49 @@ const ReviewComponent = ({ ); }; + const OptionMenu = () => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + return ( +
+ + + + + { + openReviewModal(); + handleClose(); + }} + > + + + + Edit Review + + { + openDeleteModal(); + handleClose(); + }} + > + + + + Delete Review + + +
+ ); + }; return ( @@ -339,11 +455,7 @@ const ReviewComponent = ({ {formattedDate} {user && reviewData.userId && user.uid === reviewData.userId && ( - - openReviewModal()}> - - - + {OptionMenu()} )} {isMobile && bedroomsPriceLabel()} From 9dde58fb7d26fbfd8321ae4217662956ef79860f Mon Sep 17 00:00:00 2001 From: Kea-Roy Ong <146872846+kea-roy@users.noreply.github.com> Date: Sun, 6 Oct 2024 07:53:27 -0400 Subject: [PATCH 03/10] Added documentation for ReviewComponent --- frontend/src/components/Review/Review.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/components/Review/Review.tsx b/frontend/src/components/Review/Review.tsx index 2f33386f..71226ec3 100644 --- a/frontend/src/components/Review/Review.tsx +++ b/frontend/src/components/Review/Review.tsx @@ -127,6 +127,25 @@ const useStyles = makeStyles(() => ({ }, })); +/** + * ReviewComponent is a React component that displays a review card with various functionalities + * such as liking, editing, deleting, and reporting a review. It also shows detailed information + * about the review, including ratings, text, and associated property or landlord details. + * + * @component + * @param {Props} props - The props for the ReviewComponent. + * @param {ReviewWithId} props.review - The review data to display. + * @param {boolean} props.liked - Indicates if the current user has liked the review. + * @param {boolean} props.likeLoading - Indicates if the like action is in progress. + * @param {function} props.addLike - Function to add a like to the review. + * @param {function} props.removeLike - Function to remove a like from the review. + * @param {React.Dispatch>} props.setToggle - Function to toggle a state. + * @param {function} [props.triggerEditToast] - Optional function to trigger a toast notification on edit. + * @param {firebase.User | null} props.user - The current logged-in user. + * @param {React.Dispatch>} props.setUser - Function to set the current user. + * @param {boolean} props.showLabel - Indicates if the property or landlord label should be shown. + * @returns {ReactElement} The rendered ReviewComponent. + */ const ReviewComponent = ({ review, liked, From 041f3b04681ce88d0d5cba25ca8d8cc6e5f82d2d Mon Sep 17 00:00:00 2001 From: Kea-Roy Ong <146872846+kea-roy@users.noreply.github.com> Date: Sun, 6 Oct 2024 12:48:39 -0400 Subject: [PATCH 04/10] styled option menu --- frontend/src/components/Review/Review.tsx | 29 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Review/Review.tsx b/frontend/src/components/Review/Review.tsx index 71226ec3..ea7c361b 100644 --- a/frontend/src/components/Review/Review.tsx +++ b/frontend/src/components/Review/Review.tsx @@ -125,6 +125,18 @@ const useStyles = makeStyles(() => ({ width: '21px', height: '21px', }, + reviewOptionMenu: { + borderRadius: '12px', + boxShadow: '0px 4px 4px rgba(0, 0, 0, 0.25)', + display: 'inline-grid', + gap: '8px', + }, + reviewOptionMenuItem: { + padding: '0px 16px', + }, + reviewOptionMenuItemIcon: { + minWidth: '30px', + }, })); /** @@ -174,6 +186,9 @@ const ReviewComponent = ({ bedroomsPrice, bedPriceIcon, bedroomsPriceText, + reviewOptionMenu, + reviewOptionMenuItem, + reviewOptionMenuItemIcon, } = useStyles(); const [expanded, setExpanded] = useState(false); const [expandedText, setExpandedText] = useState(false); @@ -414,15 +429,22 @@ const ReviewComponent = ({ - + { openReviewModal(); handleClose(); }} + className={reviewOptionMenuItem} > - + Edit Review @@ -433,8 +455,9 @@ const ReviewComponent = ({ openDeleteModal(); handleClose(); }} + className={reviewOptionMenuItem} > - + Delete Review From d61a7f7a001f4b832ae92c02d1baaec9134e5a60 Mon Sep 17 00:00:00 2001 From: Kea-Roy Ong <146872846+kea-roy@users.noreply.github.com> Date: Mon, 7 Oct 2024 04:29:27 -0400 Subject: [PATCH 05/10] create OptionMenu component --- frontend/src/components/utils/OptionMenu.tsx | 101 +++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 frontend/src/components/utils/OptionMenu.tsx diff --git a/frontend/src/components/utils/OptionMenu.tsx b/frontend/src/components/utils/OptionMenu.tsx new file mode 100644 index 00000000..a1d234d4 --- /dev/null +++ b/frontend/src/components/utils/OptionMenu.tsx @@ -0,0 +1,101 @@ +import { + IconButton, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import React, { useState } from 'react'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; + +const useStyles = makeStyles(() => ({ + reviewOptionMenu: { + borderRadius: '12px', + boxShadow: '0px 4px 4px rgba(0, 0, 0, 0.25)', + }, + reviewOptionMenuItem: { + padding: '0px 16px', + }, + reviewOptionMenuItemIcon: { + minWidth: '30px', + }, +})); + +type OptionMenuElement = { + icon: React.ReactNode; + text: string; + onClick: () => void; +}; + +interface OptionMenuProps { + options: OptionMenuElement[]; +} + +/** + * OptionMenu component renders a button that, when clicked, displays a menu with a list of options. + * Each option consists of an icon, text, and an onClick handler. + * + * @component + * + * @param {OptionMenuProps} props - The properties for the OptionMenu component. + * @param {OptionMenuElement[]} props.options - An array of option elements to be displayed in the menu. + * + * @typedef {Object} OptionMenuElement + * @property {React.ReactNode} icon - The icon to be displayed for the menu item. + * @property {string} text - The text to be displayed for the menu item. + * @property {() => void} onClick - The function to be called when the menu item is clicked. + * + * @typedef {Object} OptionMenuProps + * @property {OptionMenuElement[]} options - An array of option elements to be displayed in the menu. + * + * @returns {JSX.Element} The rendered OptionMenu component. + */ +export default function OptionMenu({ options }: OptionMenuProps) { + const { reviewOptionMenu, reviewOptionMenuItem, reviewOptionMenuItemIcon } = useStyles(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + return ( +
+ + + + + {options.map((option, index) => ( + { + option.onClick(); + handleClose(); + }} + className={reviewOptionMenuItem} + > + {option.icon} + {option.text} + + ))} + +
+ ); +} From e198f93b4ef22d060a1ba9260b575d4d7cf65a36 Mon Sep 17 00:00:00 2001 From: Kea-Roy Ong <146872846+kea-roy@users.noreply.github.com> Date: Mon, 7 Oct 2024 04:30:55 -0400 Subject: [PATCH 06/10] use new OptionMenu component instead of in the same file, stylized delete dialog and option menu to match figma --- frontend/src/components/Review/Review.tsx | 141 ++++++++++------------ 1 file changed, 67 insertions(+), 74 deletions(-) diff --git a/frontend/src/components/Review/Review.tsx b/frontend/src/components/Review/Review.tsx index ea7c361b..fcd25c2d 100644 --- a/frontend/src/components/Review/Review.tsx +++ b/frontend/src/components/Review/Review.tsx @@ -12,16 +12,11 @@ import { Collapse, Link, useMediaQuery, - Menu, - MenuItem, - ListItemIcon, - ListItemText, } from '@material-ui/core'; import HeartRating from '../utils/HeartRating'; import { format } from 'date-fns'; import { makeStyles } from '@material-ui/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; import EditIcon from '@material-ui/icons/Edit'; import DeleteIcon from '@material-ui/icons/Delete'; import { @@ -47,6 +42,7 @@ import { Link as RouterLink } from 'react-router-dom'; import { createAuthHeaders, getUser } from '../../utils/firebase'; import { get } from '../../utils/call'; import getPriceRange from '../../utils/priceRange'; +import OptionMenu from '../utils/OptionMenu'; import { ReactComponent as BedIcon } from '../../assets/bed-icon.svg'; import { ReactComponent as MoneyIcon } from '../../assets/money-icon.svg'; @@ -108,7 +104,7 @@ const useStyles = makeStyles(() => ({ photoRowStyle: { overflowX: 'auto', display: 'flex', - lexDirection: 'row', + flexDirection: 'row', gap: '1vw', paddingTop: '2%', paddingLeft: '0.6%', @@ -125,17 +121,44 @@ const useStyles = makeStyles(() => ({ width: '21px', height: '21px', }, - reviewOptionMenu: { - borderRadius: '12px', - boxShadow: '0px 4px 4px rgba(0, 0, 0, 0.25)', - display: 'inline-grid', - gap: '8px', + submitButton: { + borderRadius: '30px', + minWidth: '80px', + color: colors.white, + backgroundColor: colors.red1, + '&:hover': { + backgroundColor: colors.red7, + }, + }, + hollowRedButton: { + minWidth: '80px', + height: '35px', + borderRadius: '30px', + border: '2px solid', + borderColor: `${colors.red1} !important`, + backgroundColor: 'transparent', + color: colors.red1, + '&:hover': { + backgroundColor: colors.red5, + }, + }, + deleteDialogTitle: { + padding: '20px 24px 0 24px', + '& .MuiTypography-h6': { + color: colors.black, + fontSize: '18px', + fontWeight: 600, + lineHeight: '28px', + }, }, - reviewOptionMenuItem: { - padding: '0px 16px', + deleteDialogDesc: { + color: colors.black, + fontSize: '16px', + lineHeight: '24px', + fontWeight: 400, }, - reviewOptionMenuItemIcon: { - minWidth: '30px', + deleteDialogActions: { + padding: '0 20px 24px', }, })); @@ -186,9 +209,11 @@ const ReviewComponent = ({ bedroomsPrice, bedPriceIcon, bedroomsPriceText, - reviewOptionMenu, - reviewOptionMenuItem, - reviewOptionMenuItemIcon, + deleteDialogTitle, + deleteDialogDesc, + deleteDialogActions, + submitButton, + hollowRedButton, } = useStyles(); const [expanded, setExpanded] = useState(false); const [expandedText, setExpandedText] = useState(false); @@ -218,15 +243,19 @@ const ReviewComponent = ({ }} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" + PaperProps={{ style: { borderRadius: '12px' } }} > - {'Delete this review?'} + + Delete this review? + - + You will not be able to recover deleted reviews. - +