From 8300520a86edabfa7ee3be91b496e933e6292917 Mon Sep 17 00:00:00 2001 From: Takeshi Kimata <117462761+kimatata@users.noreply.github.com> Date: Sat, 1 Jun 2024 22:00:45 +0900 Subject: [PATCH] feat: implement auth for folders --- backend/middleware/auth.js | 73 ++++++++++- backend/routes/folders/delete.js | 3 +- backend/routes/folders/edit.js | 3 +- backend/routes/folders/index.js | 3 +- backend/routes/folders/new.js | 6 +- frontend/messages/en.json | 4 +- frontend/messages/ja.json | 4 +- frontend/src/app/[locale]/account/page.tsx | 1 - .../[projectId]/folders/FolderDialog.tsx | 4 +- .../[projectId]/folders/FolderEditMenu.tsx | 2 +- .../[projectId]/folders/FoldersPane.tsx | 116 +++++++++++------- .../[projectId]/folders/foldersControl.ts | 28 ++++- .../projects/[projectId]/folders/layout.tsx | 2 + frontend/types/folder.ts | 2 + 14 files changed, 188 insertions(+), 63 deletions(-) diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 51e210e..dc67525 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,9 +1,10 @@ const jwt = require('jsonwebtoken'); const { roles, memberRoles, defaultDangerKey } = require('../routes/users/authSettings'); -const defineProject = require('../models/projects'); -const defineUser = require('../models/users'); const { DataTypes } = require('sequelize'); +const defineUser = require('../models/users'); const defineMember = require('../models/members'); +const defineProject = require('../models/projects'); +const defineFolder = require('../models/folders'); function authMiddleware(sequelize) { /** @@ -56,7 +57,7 @@ function authMiddleware(sequelize) { async function verifyProjectVisible(req, res, next) { const Project = defineProject(sequelize, DataTypes); - const projectId = req.params.projectId; + const projectId = req.params.projectId || req.query.projectId; if (!projectId) { return res.status(400).json({ error: 'projectId is required' }); } @@ -100,7 +101,7 @@ function authMiddleware(sequelize) { /** * Verify user has permission of project management - * (User must be the owner or manager of the project ) + * (User must be the owner or manager of the project) * (have to be called after verifySignedIn() middleware) */ async function verifyProjectManager(req, res, next) { @@ -140,7 +141,69 @@ function authMiddleware(sequelize) { return res.status(403).json({ error: 'Forbidden' }); } - return { verifySignedIn, verifyAdmin, verifyProjectVisible, verifyProjectOwner, verifyProjectManager }; + /** + * Verify user has permission of project development + * (User must be the owner or manager or developer of the project) + * (have to be called after verifySignedIn() middleware) + */ + async function verifyProjectDeveloper(req, res, next) { + const Project = defineProject(sequelize, DataTypes); + const Folder = defineFolder(sequelize, DataTypes); + + let projectId = req.params.projectId || req.query.projectId; + const folderId = req.params.folderId || req.query.folderId; + if (!projectId && !folderId) { + return res.status(400).json({ error: 'projectId or folderId is required' }); + } + + if (!projectId) { + // find project id from folderId + const folder = await Folder.findByPk(folderId); + if (folder && folder.projectId) { + projectId = folder.projectId; + } else { + return res.status(404).send('failed to find project from folderId'); + } + } + + const project = await Project.findByPk(projectId); + if (!project) { + return res.status(404).send('Project not found'); + } + + if (project.userId === req.userId) { + next(); + return; + } + + // check the user is manager or developer of the project + const Member = defineMember(sequelize, DataTypes); + const member = await Member.findOne({ + where: { + userId: req.userId, + projectId: projectId, + }, + }); + if (member) { + const managerRoleIndex = memberRoles.findIndex((entry) => entry.uid === 'manager'); + const developerRoleIndex = memberRoles.findIndex((entry) => entry.uid === 'developer'); + if (member.role === managerRoleIndex || member.role === developerRoleIndex) { + next(); + return; + } + } + + return res.status(403).json({ error: 'Forbidden' }); + } + + return { + verifySignedIn, + verifyAdmin, + verifyProjectVisible, + verifyProjectOwner, + verifyProjectManager, + verifyProjectDeveloper, + }; } module.exports = authMiddleware; diff --git a/backend/routes/folders/delete.js b/backend/routes/folders/delete.js index 5625180..bce0b46 100644 --- a/backend/routes/folders/delete.js +++ b/backend/routes/folders/delete.js @@ -4,9 +4,10 @@ const defineFolder = require('../../models/folders'); const { DataTypes } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectDeveloper } = require('../../middleware/auth')(sequelize); const Folder = defineFolder(sequelize, DataTypes); - router.delete('/:folderId', async (req, res) => { + router.delete('/:folderId', verifySignedIn, verifyProjectDeveloper, async (req, res) => { const folderId = req.params.folderId; try { const folder = await Folder.findByPk(folderId); diff --git a/backend/routes/folders/edit.js b/backend/routes/folders/edit.js index 388afc9..6de9ff8 100644 --- a/backend/routes/folders/edit.js +++ b/backend/routes/folders/edit.js @@ -4,9 +4,10 @@ const defineFolder = require('../../models/folders'); const { DataTypes } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectDeveloper } = require('../../middleware/auth')(sequelize); const Folder = defineFolder(sequelize, DataTypes); - router.put('/:folderId', async (req, res) => { + router.put('/:folderId', verifySignedIn, verifyProjectDeveloper, async (req, res) => { const folderId = req.params.folderId; const { name, detail, projectId, parentFolderId } = req.body; try { diff --git a/backend/routes/folders/index.js b/backend/routes/folders/index.js index 7f0c877..38eb6f9 100644 --- a/backend/routes/folders/index.js +++ b/backend/routes/folders/index.js @@ -4,9 +4,10 @@ const defineFolder = require('../../models/folders'); const { DataTypes } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectVisible } = require('../../middleware/auth')(sequelize); const Folder = defineFolder(sequelize, DataTypes); - router.get('/', async (req, res) => { + router.get('/', verifySignedIn, verifyProjectVisible, async (req, res) => { const { projectId } = req.query; if (!projectId) { diff --git a/backend/routes/folders/new.js b/backend/routes/folders/new.js index 5296009..0fd6e32 100644 --- a/backend/routes/folders/new.js +++ b/backend/routes/folders/new.js @@ -4,11 +4,13 @@ const defineFolder = require('../../models/folders'); const { DataTypes } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectDeveloper } = require('../../middleware/auth')(sequelize); const Folder = defineFolder(sequelize, DataTypes); - router.post('/', async (req, res) => { + router.post('/', verifySignedIn, verifyProjectDeveloper, async (req, res) => { try { - const { name, detail, projectId, parentFolderId } = req.body; + const projectId = req.query.projectId; + const { name, detail, parentFolderId } = req.body; if (!name || !projectId) { return res.status(400).json({ error: 'Name and projectId are required' }); } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b48e6ec..4efe385 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -138,7 +138,9 @@ "close": "Close", "create": "Create", "update": "Update", - "please_enter": "Please enter folder name" + "please_enter": "Please enter folder name", + "delete": "Delete", + "are_you_sure": "Are you sure you want to delete the folder?" }, "Cases": { "test_case_list": "Test Case List", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 8af2270..7241688 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -137,7 +137,9 @@ "close": "閉じる", "create": "作成", "update": "更新", - "please_enter": "フォルダ名を入力してください" + "please_enter": "フォルダー名を入力してください", + "delete": "削除", + "are_you_sure": "フォルダーを削除してもよろしいですか?" }, "Cases": { "test_case_list": "テストケース一覧", diff --git a/frontend/src/app/[locale]/account/page.tsx b/frontend/src/app/[locale]/account/page.tsx index 50ee26c..cf7fad0 100644 --- a/frontend/src/app/[locale]/account/page.tsx +++ b/frontend/src/app/[locale]/account/page.tsx @@ -4,7 +4,6 @@ export default function Page(params: { locale: string }) { const t = useTranslations('Auth'); const messages = { yourProjects: t('your_projects'), - moveToAdmin: t('move_to_admin'), }; return ; diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FolderDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderDialog.tsx index 7320d95..2e2a908 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/FolderDialog.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderDialog.tsx @@ -6,7 +6,7 @@ import { FolderType, FoldersMessages } from '@/types/folder'; type Props = { isOpen: boolean; - editingFolder: FolderType; + editingFolder: FolderType | null; onCancel: () => void; onSubmit: (name: string, detail: string) => void; messages: FoldersMessages; @@ -66,7 +66,7 @@ export default function FolderDialog({ isOpen, editingFolder, onCancel, onSubmit if (!folderName.text) { setFolderName({ text: '', - isValid: false, + isValid: true, errorMessage: messages.pleaseEnter, }); diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FolderEditMenu.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderEditMenu.tsx index adf12d0..2c06d5c 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/FolderEditMenu.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FolderEditMenu.tsx @@ -5,7 +5,7 @@ import { FolderType, FoldersMessages } from '@/types/folder'; type Props = { folder: FolderType; onEditClick: (folder: FolderType) => void; - onDeleteClick: (folder: FolderType) => void; + onDeleteClick: (deleteFolderId: number) => void; messages: FoldersMessages; }; diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx index 94fbe4a..c7428a7 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/FoldersPane.tsx @@ -1,14 +1,14 @@ 'use client'; -import React from 'react'; import { FolderType, FoldersMessages } from '@/types/folder'; -import { useEffect, useState } from 'react'; +import { useState, useEffect, useContext } from 'react'; import { Button, Listbox, ListboxItem } from '@nextui-org/react'; import { Folder, Plus } from 'lucide-react'; import { usePathname, useRouter } from '@/src/navigation'; +import { TokenContext } from '@/utils/TokenProvider'; import useGetCurrentIds from '@/utils/useGetCurrentIds'; import FolderDialog from './FolderDialog'; import FolderEditMenu from './FolderEditMenu'; - +import DeleteConfirmDialog from '@/components/DeleteConfirmDialog'; import { fetchFolders, createFolder, updateFolder, deleteFolder } from './foldersControl'; type Props = { @@ -20,50 +20,20 @@ type Props = { export default function FoldersPane({ projectId, messages, locale }: Props) { const router = useRouter(); const pathname = usePathname(); - const [folders, setFolders] = useState([]); - const [selectedFolder, setSelectedFolder] = useState({}); - + const context = useContext(TokenContext); + const [folders, setFolders] = useState([]); + const [selectedFolder, setSelectedFolder] = useState(null); const { folderId } = useGetCurrentIds(); - const [isFolderDialogOpen, setIsFolderDialogOpen] = useState(false); const [editingFolder, setEditingProject] = useState(null); - const openDialogForCreate = () => { - setIsFolderDialogOpen(true); - setEditingProject(null); - }; - - const closeDialog = () => { - setIsFolderDialogOpen(false); - setEditingProject(null); - }; - - const onSubmit = async (name: string, detail: string) => { - if (editingFolder) { - const updatedProject = await updateFolder(editingFolder.id, name, detail, projectId, null); - const updatedProjects = folders.map((project) => (project.id === updatedProject.id ? updatedProject : project)); - setFolders(updatedProjects); - } else { - const newProject = await createFolder(name, detail, projectId, null); - setFolders([...folders, newProject]); - } - closeDialog(); - }; - - const onEditClick = (folder: FolderType) => { - setEditingProject(folder); - setIsFolderDialogOpen(true); - }; - - const onDeleteClick = async (folderId: number) => { - await deleteFolder(folderId); - router.push(`/projects/${projectId}/folders`, { locale: locale }); - }; - useEffect(() => { async function fetchDataEffect() { + if (!context.isSignedIn()) { + return; + } try { - const data = await fetchFolders(projectId); + const data = await fetchFolders(context.token.access_token, projectId); setFolders(data); const selectedFolderFromUrl = data.find((folder) => folder.id === folderId); @@ -80,7 +50,62 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { } fetchDataEffect(); - }, [folderId]); + }, [context, folderId]); + + const openDialogForCreate = () => { + setIsFolderDialogOpen(true); + setEditingProject(null); + }; + + const closeDialog = () => { + setIsFolderDialogOpen(false); + setEditingProject(null); + }; + + const onSubmit = async (name: string, detail: string) => { + if (editingFolder) { + const updatedProject = await updateFolder( + context.token.access_token, + editingFolder.id, + name, + detail, + projectId, + null + ); + const updatedProjects = folders.map((project) => (project.id === updatedProject.id ? updatedProject : project)); + setFolders(updatedProjects); + } else { + const newProject = await createFolder(context.token.access_token, name, detail, projectId, null); + setFolders([...folders, newProject]); + } + closeDialog(); + }; + + const onEditClick = (folder: FolderType) => { + setEditingProject(folder); + setIsFolderDialogOpen(true); + }; + + // Delete confirm dialog + const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = useState(false); + const [deleteFolderId, setDeleteFolderId] = useState(null); + const closeDeleteConfirmDialog = () => { + setIsDeleteConfirmDialogOpen(false); + setDeleteFolderId(null); + }; + + const onDeleteClick = (deleteFolderId: number) => { + setDeleteFolderId(deleteFolderId); + setIsDeleteConfirmDialogOpen(true); + }; + + const onConfirm = async () => { + if (deleteFolderId) { + await deleteFolder(context.token.access_token, deleteFolderId); + router.push(`/projects/${projectId}/folders`, { locale: locale }); + closeDeleteConfirmDialog(); + } + }; const baseClass = ''; const selectedClass = `${baseClass} bg-neutral-200 dark:bg-neutral-700`; @@ -126,6 +151,15 @@ export default function FoldersPane({ projectId, messages, locale }: Props) { onSubmit={onSubmit} messages={messages} /> + + ); } diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/foldersControl.ts b/frontend/src/app/[locale]/projects/[projectId]/folders/foldersControl.ts index ed8f9df..452b18b 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/foldersControl.ts +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/foldersControl.ts @@ -4,13 +4,14 @@ const apiServer = Config.apiServer; /** * fetch folder records */ -async function fetchFolders(projectId: string) { +async function fetchFolders(jwt: string, projectId: string) { try { const url = `${apiServer}/folders?projectId=${projectId}`; const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, }, }); @@ -28,11 +29,16 @@ async function fetchFolders(projectId: string) { /** * Create project */ -async function createFolder(name: string, detail: string, projectId: strting, parentFolderId: number) { +async function createFolder( + jwt: string, + name: string, + detail: string, + projectId: string, + parentFolderId: number | null +) { const newFolderData = { name: name, detail: detail, - projectId: projectId, parentFolderId: parentFolderId, }; @@ -40,11 +46,12 @@ async function createFolder(name: string, detail: string, projectId: strting, pa method: 'POST', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, }, body: JSON.stringify(newFolderData), }; - const url = `${apiServer}/folders`; + const url = `${apiServer}/folders?projectId=${projectId}`; try { const response = await fetch(url, fetchOptions); @@ -62,7 +69,14 @@ async function createFolder(name: string, detail: string, projectId: strting, pa /** * Update folder */ -async function updateFolder(folderId: number, name: string, detail: string, projectId: string, parentFolderId: number) { +async function updateFolder( + jwt: string, + folderId: number, + name: string, + detail: string, + projectId: string, + parentFolderId: number | null +) { const updateFolderData = { name: name, detail: detail, @@ -74,6 +88,7 @@ async function updateFolder(folderId: number, name: string, detail: string, proj method: 'PUT', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, }, body: JSON.stringify(updateFolderData), }; @@ -96,11 +111,12 @@ async function updateFolder(folderId: number, name: string, detail: string, proj /** * Delete folder */ -async function deleteFolder(folderId: number) { +async function deleteFolder(jwt: string, folderId: number) { const fetchOptions = { method: 'DELETE', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, }, }; diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/layout.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/layout.tsx index 5a4e0fa..0a29384 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/layout.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/layout.tsx @@ -20,6 +20,8 @@ export default function FoldersLayout({ create: t('create'), update: t('update'), pleaseEnter: t('please_enter'), + delete: t('delete'), + areYouSure: t('are_you_sure'), }; return ( diff --git a/frontend/types/folder.ts b/frontend/types/folder.ts index f654f38..42d2167 100644 --- a/frontend/types/folder.ts +++ b/frontend/types/folder.ts @@ -22,4 +22,6 @@ export type FoldersMessages = { create: string; update: string; pleaseEnter: string; + delete: string; + areYouSure: string; };