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;
};