From a45f3d9f6b91fb9b11099df93ff3f8a8dc820917 Mon Sep 17 00:00:00 2001 From: Kristopher Santos Date: Tue, 25 Mar 2025 11:22:00 +0800 Subject: [PATCH] FIX & FEAT: FE and BE role based validation - Added role validation to backend routes - Added feature to disable frontend interaction depending on the role - Added a role return when fetching a project by id - Added notification to some interactions for easier user feedback --- server/controllers/memberControllers.ts | 2 +- server/controllers/projectController.ts | 23 +++++--- .../validators/memberRoleValidator.ts | 55 +++++++++++++------ .../middleware/validators/projectValidator.ts | 2 +- server/routes/memberRoutes.ts | 10 ++-- server/routes/projectRoutes.ts | 15 +++-- server/types/authTypes.ts | 5 ++ src/data/repo/useDataInitializer.ts | 34 +++++++++--- src/data/repo/useMemberRepo.ts | 2 +- src/data/repo/useProjectRepo.ts | 29 +++++++--- src/data/repo/useUserRepo.ts | 4 ++ src/layouts/TopLeftBar.tsx | 43 ++++++++++----- src/layouts/TopRightBar.tsx | 8 ++- src/pages/editor/index.tsx | 10 +--- src/store/useMemberStore.ts | 8 --- src/store/useProjectStore.ts | 9 ++- src/types/APITypes.ts | 1 + 17 files changed, 178 insertions(+), 82 deletions(-) diff --git a/server/controllers/memberControllers.ts b/server/controllers/memberControllers.ts index 0c3909b..c7b8623 100644 --- a/server/controllers/memberControllers.ts +++ b/server/controllers/memberControllers.ts @@ -156,7 +156,7 @@ export const getMemberRoleById = async ( throw new ConflictError("The user is not a member of the project"); next( - new SuccessResponse("Member role has been updated successfully.", { + new SuccessResponse("Member role has been fetched successfully.", { role: member.members[0].role, }) ); diff --git a/server/controllers/projectController.ts b/server/controllers/projectController.ts index 93e6e81..739bd98 100644 --- a/server/controllers/projectController.ts +++ b/server/controllers/projectController.ts @@ -8,6 +8,7 @@ import User from "@root/models/userModel.ts"; import SuccessResponse from "@root/success/SuccessResponse.ts"; import CreatedResponse from "@root/success/CreatedResponse.ts"; import Changelog from "@root/models/changelogModel.ts"; +import { ValidatedRoleRequest } from "@root/types/authTypes.ts"; /** * Get projects based on query parameters. @@ -80,20 +81,28 @@ export const getAllProjects = async ( // Get a project by ID export const getProjectById = async ( - req: Request, + req: ValidatedRoleRequest, res: Response, next: NextFunction ) => { try { + const userId = req.userId + // Find the project by ID - const project = await Project.findById(req.params.id).select('-members'); + const project = await Project.findById(req.params.projectId); // Check if the project exists if (!project) throw new NotFoundError("Project not found."); + const member = project.members.find((user) => user.userId.toString() === userId) + const userRole = member ? member.role : null + + const { members, ...projectData} = project.toJSON() + next( new SuccessResponse("Successfully fetched the project.", { - project, + project: projectData, + userRole }) ); } catch (err: any) { @@ -109,14 +118,14 @@ export const editProject = async ( ) => { try { // Find the project by ID - const project = await Project.findById(req.params.id); + const project = await Project.findById(req.params.projectId); // Check if the project exists if (!project) throw new NotFoundError("Project not found."); // Update the project const updatedProject = await Project.findByIdAndUpdate( - req.params.id, + req.params.projectId, req.body, { new: true } ).select('-data -members'); @@ -184,7 +193,7 @@ export const saveProject = async ( ) => { try { // Find the project by ID - const project = await Project.findById(req.params.id).select('-members'); + const project = await Project.findById(req.params.projectId).select('-members'); const { data, members = [] } = req.body; // Check if the project exists @@ -238,7 +247,7 @@ export const deleteProject = async ( next: NextFunction ) => { try { - const project = await Project.findByIdAndDelete(req.params.id); + const project = await Project.findByIdAndDelete(req.params.projectId); if (!project) throw new NotFoundError("Project not found."); diff --git a/server/middleware/validators/memberRoleValidator.ts b/server/middleware/validators/memberRoleValidator.ts index 4418d1d..31312f4 100644 --- a/server/middleware/validators/memberRoleValidator.ts +++ b/server/middleware/validators/memberRoleValidator.ts @@ -1,35 +1,58 @@ +import NotFoundError from "@root/errors/NotFoundError"; import ValidationError from "@root/errors/ValidationError"; import Project from "@root/models/projectModel" +import User from "@root/models/userModel"; import { Response, NextFunction } from "express"; + +const getMemberByEmail = async (email: string) => { + const user = await User.findOne( + { email: email } + ) + + if (!user) { + console.log("User not found") + throw new NotFoundError("User not found"); + } + + return user._id.toString() +} + const getMemberRole = async (projectId: string, userId: string) => { - const project = await Project.findOne( - { _id: projectId, "members.userId": userId }, - { "members.$": 1 } - ); + const project = await Project.findById(projectId).select("members generalAccess"); - console.log(projectId, userId) + if (!project) { + console.log("Project not found") + throw new NotFoundError("Project not found"); + } + + const member = project.members.find((user) => user.userId.toString() === userId); + const memberRole = member ? member.role : null; + const generalRole = project.generalAccess?.role || null; + const accessType = project.generalAccess?.accessType || "Restricted"; - if (!project || project.members.length === 0) { - console.log("Project or member not found") - return null; + if (accessType === "Restricted" && !memberRole) { + console.log("Access denied: User is not a member of the project."); + throw new NotFoundError("Access denied: User is not a member."); } - console.log("Got the member role") - return project.members[0].role; + return { memberRole, generalRole }; } export const checkRole = (allowedRoles: string[]) => async (req: any, res: Response, next: NextFunction) => { try { const { projectId } = req.params; - const userId = req.passport; - - console.log(req.user) + const userId = await getMemberByEmail(req.user.email); - const role = await getMemberRole(projectId, userId) + const { memberRole, generalRole } = await getMemberRole(projectId, userId) - if (!role || !allowedRoles.includes(role)) - throw new ValidationError("Access denied") + const hasAccess = (memberRole && allowedRoles.includes(memberRole)) || (generalRole && allowedRoles.includes(generalRole)); + + if (!hasAccess) { + throw new ValidationError("Access denied: Insufficient permissions."); + } + + req.userId = userId; next() } catch (error) { diff --git a/server/middleware/validators/projectValidator.ts b/server/middleware/validators/projectValidator.ts index e312799..efa3023 100644 --- a/server/middleware/validators/projectValidator.ts +++ b/server/middleware/validators/projectValidator.ts @@ -10,7 +10,7 @@ export const validate = (req: Request, res: Response, next: NextFunction) => { next(); }; -export const validateProjectId = param("id") +export const validateProjectId = param("projectId") .trim() .isMongoId() .withMessage("The project ID must be a valid MongoDB ID."); diff --git a/server/routes/memberRoutes.ts b/server/routes/memberRoutes.ts index 83c43b6..15e39c6 100644 --- a/server/routes/memberRoutes.ts +++ b/server/routes/memberRoutes.ts @@ -24,6 +24,7 @@ const router = Router(); router.get( "/:projectId/members", [validateToken, validateProjectId, validate], + checkRole(["Viewer", "Editor", "Admin", "Owner"]), getMembersByProjectId ); router.post( @@ -34,7 +35,7 @@ router.post( validateRoleOptional, validate, ], - // checkRole(["Editor", "Admin", "Owner"]), + checkRole(["Editor", "Admin", "Owner"]), addMember ); router.patch( @@ -46,7 +47,7 @@ router.patch( validateRoleRequired, validate, ], - // checkRole(["Admin", "Owner"]), + checkRole(["Admin", "Owner"]), editMemberRole ); router.get( @@ -57,12 +58,13 @@ router.get( validateUserId, validate, ], + checkRole(["Viewer", "Editor", "Admin", "Owner"]), getMemberRoleById ); router.delete( "/:projectId/members/:userId", [validateToken, validateProjectId, validateUserId, validate], - // checkRole(["Admin", "Owner"]), + checkRole(["Admin", "Owner"]), removeMember ); router.patch( @@ -74,7 +76,7 @@ router.patch( validateAccessRole, validate ], - // checkRole(["Admin", "Owner"]), + checkRole(["Admin", "Owner"]), editGeneralAccess ) diff --git a/server/routes/projectRoutes.ts b/server/routes/projectRoutes.ts index efd2c5d..02619d6 100644 --- a/server/routes/projectRoutes.ts +++ b/server/routes/projectRoutes.ts @@ -23,6 +23,7 @@ import { validateOnlyDataField, } from "@root/middleware/validators/projectValidator"; import { validateToken } from "@root/middleware/validators/authValidator"; +import { checkRole } from "@root/middleware/validators/memberRoleValidator"; const router = express.Router(); @@ -44,7 +45,12 @@ router.get("", [validateToken, validateUserIdQuery, validate], getProjects); * - validateProjectId: Ensures the provided project ID is valid. * - validate: General validation middleware. */ -router.get("/:id", [validateToken, validateProjectId, validate], getProjectById); +router.get( + "/:projectId", + [validateToken, validateProjectId, validate], + checkRole(["Viewer", "Editor", "Admin", "Owner"]), + getProjectById +); /** * PATCH /projects/:id @@ -57,7 +63,7 @@ router.get("/:id", [validateToken, validateProjectId, validate], getProjectById) * - validate: General validation middleware. */ router.patch( - "/:id", + "/:projectId", [ validateToken, validateProjectId, @@ -80,7 +86,7 @@ router.patch( * */ router.patch( - "/:id/data", + "/:projectId/data", [ validateToken, validateProjectId, @@ -88,6 +94,7 @@ router.patch( validateOnlyDataField, validate, ], + checkRole(["Viewer", "Editor", "Admin", "Owner"]), saveProject ); @@ -113,7 +120,7 @@ router.post( * - validate: General validation middleware. */ router.delete( - "/:id", + "/:projectId", [validateToken, validateProjectId, validate], deleteProject ); diff --git a/server/types/authTypes.ts b/server/types/authTypes.ts index fbf5629..a21556d 100644 --- a/server/types/authTypes.ts +++ b/server/types/authTypes.ts @@ -1,6 +1,11 @@ import { Request } from "express"; import { Session } from "express-session"; + +export interface ValidatedRoleRequest extends Request { + userId?: string +} + export interface AuthRequest

extends Request { session: CustomSession; user?: any; diff --git a/src/data/repo/useDataInitializer.ts b/src/data/repo/useDataInitializer.ts index 888c991..d2119b5 100644 --- a/src/data/repo/useDataInitializer.ts +++ b/src/data/repo/useDataInitializer.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { useEmojiStore } from "../../store/globalStore"; import { getProjectsApi } from "../api/projectsApi"; import { db } from "../db/db"; @@ -9,6 +9,8 @@ import useHistoryRepo from "./useHistoryRepo"; import useProjectRepo from "./useProjectRepo"; import { useSettingsRepo } from "./useSettingsRepo"; import useUserRepo from "./useUserRepo"; +import CustomNotification from "../../components/ui/CustomNotification"; +import { AxiosError } from "axios"; export const useDataInitializer = () => { const { user } = useUserRepo(); @@ -16,6 +18,7 @@ export const useDataInitializer = () => { const { loadChangelogs } = useChangelogRepo(); const { fetchUserSettings } = useSettingsRepo(); const { resetHistory } = useHistoryRepo(); + const navigate = useNavigate(); // Router const params = useParams(); @@ -35,19 +38,34 @@ export const useDataInitializer = () => { }, []); useEffect(() => { - if (id && user) { - selectProject(id, user.id); - loadChangelogs(id); - resetHistory(); + const loadSelectedProject = async () => { + try { + if (id && user && isLoaded) { + const res = await selectProject(id); + resetHistory(); + if (res) { + loadChangelogs(id); + } + + } + } catch (err) { + if (err instanceof AxiosError) { + CustomNotification({ + status: "error", + title: "Failed to Load Project", + message: err.response?.data.message, + }); + } + navigate("/", { replace: true }) + } } + + loadSelectedProject() }, [id, isLoaded]); const loadProjects = async () => { console.log("Loading projects from local storage"); - // Dexie fetching of projects - // NOTE: Not needed anymore - // const projectList = await db.projects.toArray(); if (!user) return; const getProjectList = await getProjectsApi(user?.id); diff --git a/src/data/repo/useMemberRepo.ts b/src/data/repo/useMemberRepo.ts index dad9797..4f338f9 100644 --- a/src/data/repo/useMemberRepo.ts +++ b/src/data/repo/useMemberRepo.ts @@ -37,7 +37,7 @@ const useMemberRepo = () => { try { setLoading(true); setError(null); - + console.log("Get member") const response = await getProjectMembersApi(projectId); if (response.success) { diff --git a/src/data/repo/useProjectRepo.ts b/src/data/repo/useProjectRepo.ts index e76e231..e270707 100644 --- a/src/data/repo/useProjectRepo.ts +++ b/src/data/repo/useProjectRepo.ts @@ -15,14 +15,21 @@ import { getMemberRoleApi } from "../api/membersApi"; const useProjectRepo = () => { const projects = useProjectStore((state) => state.projects); const selectedProject = useProjectStore((state) => state.selectedProject); + const selectedProjectRole = useProjectStore((state) => state.selectedProjectRole); const setProjects = useProjectStore((state) => state.setProjects); - const getProjects = useProjectStore((state) => state.getProjects); + const setSelectedProject = useProjectStore( (state) => state.setSelectedProject ); const clearSelectedProject = useProjectStore( (state) => state.clearSelectedProject ); + const setSelectedProjectRole = useProjectStore( + (state) => state.setSelectedProjectRole + ); + const clearSelectedProjectRole = useProjectStore( + (state) => state.clearSelectedProjectRole + ); const clearStateSnap = useEditorStore((state) => state.clearStateSnapshot); const addState = useProjectStore((state) => state.addProject); @@ -41,7 +48,6 @@ const useProjectRepo = () => { const setCanUndo = useEditorStore((state) => state.setCanUndo); const setCanRedo = useEditorStore((state) => state.setCanRedo); - const setAccess = useMemberStore((state) => state.setCurrentProjectAccess) const getProjectsList = () => { return projects; @@ -97,7 +103,7 @@ const useProjectRepo = () => { setCanRedo(false); }; - const selectProject = async (projectId: string | undefined, userId: string | undefined) => { + const selectProject = async (projectId: string | undefined) => { if (!projectId) return; // Save the previous current project to cache @@ -107,7 +113,9 @@ const useProjectRepo = () => { // Fetch the new selected project const res = await getProjectByIdApi(projectId); + console.log(res) const selected = res.data.project; + const role = res.data.userRole const cache = getCache().find((item) => item.id === projectId); if (cache) { @@ -119,17 +127,15 @@ const useProjectRepo = () => { } setSelectedProject(selected); + setSelectedProjectRole(role); - if (!userId) return; - - const memberRes = await getMemberRoleApi(projectId, userId); - - setAccess(memberRes.data.role) + return res }; const clearProject = () => { // Set selected project to none clearSelectedProject(); + clearSelectedProjectRole(); clearStateSnap(); // Delete cleared project from cache @@ -195,12 +201,18 @@ const useProjectRepo = () => { // Implement the duplication of project in the backend }; + const validateRole = () => { + if(!selectedProject) return false; + return selectedProject.generalAccess.role != "Viewer" || (selectedProjectRole != null && selectedProjectRole != "Viewer") + }; + return { projects, getProjectsList, setProjectList, getProjectById, selectedProject, + selectedProjectRole, selectProject, clearProject, addProject, @@ -208,6 +220,7 @@ const useProjectRepo = () => { deleteProject, duplicateProject, loadProjectData, + validateRole }; }; diff --git a/src/data/repo/useUserRepo.ts b/src/data/repo/useUserRepo.ts index 80a5c0b..6a1e14a 100644 --- a/src/data/repo/useUserRepo.ts +++ b/src/data/repo/useUserRepo.ts @@ -23,6 +23,9 @@ const useUserRepo = () => { const clearSelectedProject = useProjectStore( (state) => state.clearSelectedProject ); + const clearSelectedProjectRole = useProjectStore( + (state) => state.clearSelectedProjectRole + ); const clearStateSnapshot = useEditorStore( (state) => state.clearStateSnapshot ); @@ -144,6 +147,7 @@ const useUserRepo = () => { // Clear the selected project clearSelectedProject(); + clearSelectedProjectRole(); // Clear the editor state snapshot clearStateSnapshot(); diff --git a/src/layouts/TopLeftBar.tsx b/src/layouts/TopLeftBar.tsx index ae0e362..c1422b3 100644 --- a/src/layouts/TopLeftBar.tsx +++ b/src/layouts/TopLeftBar.tsx @@ -44,6 +44,7 @@ import { determineTitle } from "../utils/successHelpers"; import useMemberRepo from "../data/repo/useMemberRepo"; import { useMemberStore } from "../store/useMemberStore"; import CustomNotification from "../components/ui/CustomNotification"; +import { APIResponse, SavedProject } from "../types/APITypes"; function TopLeftBar() { const [drawerLocalStorage, setDrawerLocalStorage] = useLocalStorage({ @@ -81,21 +82,37 @@ function ActionButtons({ const { onSave, onUndo, canUndo, canRedo, onRedo } = useHistoryRepo(); const { saveChangelog } = useChangelogRepo(); const { hasPendingChanges, } = useEditorStore(); - const { selectedProject } = useProjectRepo() - const { currentProjectAccess } = useMemberStore() + const { validateRole } = useProjectRepo() const handleSave = async () => { - const res = await onSave(); - - if (res?.success) { - const changelog = res.data.changelog; - await saveChangelog(changelog); + try { + const res = await onSave(); + + if (res?.success) { + const changelog = res.data.changelog; + saveChangelog(changelog); + showNotification(res); + } + } catch (err) { + showNotification({ + success: false, + message: "An error has occured while saving the project", + } as APIResponse); } + }; - const validateRole = () => { - if(!selectedProject) return false; - return selectedProject.generalAccess.role != "Viewer" || currentProjectAccess != "Viewer" + const showNotification = (response: APIResponse) => { + // Show notification + CustomNotification({ + status: response.success ? "success" : "error", + title: determineTitle( + "Saved project", + "Failed to save the project", + response.success + ), + message: response.message, + }); }; return ( @@ -211,7 +228,7 @@ function DrawerHeader() { // Only attempt to add the project if the success status is true if (response.success) { // Select the project on creation in STATE - selectProject(response.data.createdProject.id, user.id); + selectProject(response.data.createdProject.id); loadChangelogs(response.data.createdProject.id); @@ -314,7 +331,7 @@ function DrawerItems({ project }: { project: IProject }) { }} onClick={() => { navigate(`/${project.id}`, { replace: true }); - selectProject(project.id, user?.id); + selectProject(project.id); loadChangelogs(project.id); }} rightSection={ @@ -388,7 +405,7 @@ function DrawerItemMenu({ // If the user is currently editing the selected project, // just reselect the project to update the values. if (params.projectId == res.data.updatedProject.id) - selectProject(params.projectId, user?.id); + selectProject(params.projectId); } // Show notification diff --git a/src/layouts/TopRightBar.tsx b/src/layouts/TopRightBar.tsx index c349660..de89451 100644 --- a/src/layouts/TopRightBar.tsx +++ b/src/layouts/TopRightBar.tsx @@ -218,7 +218,7 @@ function DrawerHeader() { function HistoryTimeline() { const { changelogs, activeChangelog, selectChangelog } = useChangelogRepo(); - const { selectedProject, loadProjectData } = useProjectRepo(); + const { selectedProject, loadProjectData, validateRole } = useProjectRepo(); const handleSelectLog = async (changelog: IChangelog) => { if (!selectedProject) return; @@ -256,6 +256,7 @@ function HistoryTimeline() { } memberChanges={item.members || []} onClick={() => handleSelectLog(item)} + disabled={!validateRole()} /> ))} @@ -268,6 +269,7 @@ interface HistoryItemProps { dateTime: Date; currentVersion: boolean; memberChanges: IMember[]; + disabled?: boolean onClick: () => void; } @@ -275,6 +277,7 @@ function HistoryItem({ dateTime, currentVersion, memberChanges, + disabled, onClick, }: HistoryItemProps) { const dateFormat = new Intl.DateTimeFormat("en-US", { @@ -290,10 +293,11 @@ function HistoryItem({ return ( diff --git a/src/pages/editor/index.tsx b/src/pages/editor/index.tsx index 2c5fe9f..9181231 100644 --- a/src/pages/editor/index.tsx +++ b/src/pages/editor/index.tsx @@ -29,8 +29,8 @@ import useProjectRepo from "../../data/repo/useProjectRepo"; function Editor() { const { moveNode, addEdge, changeEdge, deleteEdge } = useEditorRepo(); - const { selectedProject } = useProjectRepo() - const { currentProjectAccess } = useMemberStore() + const { validateRole } = useProjectRepo() + //const { currentProjectAccess } = useMemberStore() const nodeTypes: NodeTypes = useMemo( () => ({ @@ -94,12 +94,6 @@ function Editor() { }, [useEditorRepo()]); - const validateRole = () => { - if(!selectedProject) return false; - return selectedProject.generalAccess.role != "Viewer" || currentProjectAccess != "Viewer" - }; - - return ( ; isLoading: boolean; - currentProjectAccess: null | "Viewer" | "Editor" | "Admin" | "Owner" error: string | null; } interface IMemberActions { setProjectMembers: (projectId: string, members: IProjectMembers[]) => void; clearProjectMembers: (projectId: string) => void; - setCurrentProjectAccess: (role: null | MemberRole) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; } @@ -21,7 +19,6 @@ export const useMemberStore = create()( devtools( (set, get) => ({ projectMembers: {}, - currentProjectAccess: null, isLoading: false, error: null, @@ -39,11 +36,6 @@ export const useMemberStore = create()( return { projectMembers: rest }; }), - setCurrentProjectAccess: (role) => - set(() => ({ - currentProjectAccess: role - })), - setLoading: (loading) => set(() => ({ isLoading: loading })), setError: (error) => set(() => ({ error })), }), diff --git a/src/store/useProjectStore.ts b/src/store/useProjectStore.ts index d13654a..8d81956 100644 --- a/src/store/useProjectStore.ts +++ b/src/store/useProjectStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { IProject, IProjectAccess } from "../types/ProjectTypes"; +import { IProject, IProjectAccess, MemberRole } from "../types/ProjectTypes"; import { IEditorStateSnapshot } from "../types/EditorStoreTypes"; import { devtools } from "zustand/middleware"; @@ -11,6 +11,7 @@ interface IProjectCache { interface IProjectState { projects: IProject[]; selectedProject: IProject | null; + selectedProjectRole: MemberRole | null projectStateCache: IProjectCache[]; } @@ -18,7 +19,9 @@ interface IProjectActions { setProjects: (projects: IProject[]) => void; getProjects: () => IProject[]; setSelectedProject: (project: IProject) => void; + setSelectedProjectRole: (role: MemberRole) => void; clearSelectedProject: () => void; + clearSelectedProjectRole: () => void; addProject: (project: IProject) => void; editProject: (id: string, change: Partial) => void; editSelectedProjectAccess: (access: IProjectAccess) => void; @@ -33,6 +36,7 @@ export const useProjectStore = create()( (set, get) => ({ projects: [], selectedProject: null, + selectedProjectRole: null, projectStateCache: [], setProjects: (projects) => set(() => ({ projects: projects })), @@ -42,6 +46,9 @@ export const useProjectStore = create()( setSelectedProject: (project) => set(() => ({ selectedProject: project })), clearSelectedProject: () => set(() => ({ selectedProject: null })), + setSelectedProjectRole: (role) => + set(() => ({ selectedProjectRole: role })), + clearSelectedProjectRole: () => set(() => ({ selectedProjectRole: null })), addProject: (project) => set((state) => ({ diff --git a/src/types/APITypes.ts b/src/types/APITypes.ts index 28525e8..7a28c08 100644 --- a/src/types/APITypes.ts +++ b/src/types/APITypes.ts @@ -25,6 +25,7 @@ export interface UpdatedProject { export interface FetchedProject { project: IProject; + userRole: MemberRole; } export interface FetchedProjects {