From abf2d48beebe7e1fab52ac6968b2f01255d088c3 Mon Sep 17 00:00:00 2001 From: Takeshi Kimata <117462761+kimatata@users.noreply.github.com> Date: Wed, 29 May 2024 23:12:38 +0900 Subject: [PATCH] Project member management --- backend/index.js | 10 ++ backend/middleware/auth.js | 47 +++++++- .../20240529221509-create-members.js | 52 +++++++++ backend/models/members.js | 31 +++++ backend/routes/members/delete.js | 36 ++++++ backend/routes/members/edit.js | 38 ++++++ backend/routes/members/index.js | 40 +++++++ backend/routes/members/new.js | 40 +++++++ backend/routes/users/authSettings.js | 3 +- backend/seeders/seed.js | 17 +++ frontend/config/selection.ts | 3 +- frontend/messages/en.json | 16 ++- frontend/messages/ja.json | 16 ++- .../[locale]/projects/[projectId]/Sidebar.tsx | 11 +- .../[locale]/projects/[projectId]/layout.tsx | 1 + .../[projectId]/settings/MembersTable.tsx | 108 ++++++++++++++++++ .../[projectId]/settings/SettingsPage.tsx | 96 ++++++++++++++++ .../projects/[projectId]/settings/page.tsx | 23 ++++ frontend/types/project.ts | 1 + frontend/types/settings.ts | 8 ++ 20 files changed, 588 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/20240529221509-create-members.js create mode 100644 backend/models/members.js create mode 100644 backend/routes/members/delete.js create mode 100644 backend/routes/members/edit.js create mode 100644 backend/routes/members/index.js create mode 100644 backend/routes/members/new.js create mode 100644 frontend/src/app/[locale]/projects/[projectId]/settings/MembersTable.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx create mode 100644 frontend/src/app/[locale]/projects/[projectId]/settings/page.tsx create mode 100644 frontend/types/settings.ts diff --git a/backend/index.js b/backend/index.js index 17b9687..5670a27 100644 --- a/backend/index.js +++ b/backend/index.js @@ -112,6 +112,16 @@ app.use('/runcases', runCaseBuldNewRoute); app.use('/runcases', runCaseDeleteRoute); app.use('/runcases', runCaseBulkDeleteRoute); +// "/members" +const membersIndexRoute = require('./routes/members/index')(sequelize); +const membersNewRoute = require('./routes/members/new')(sequelize); +const membersEditRoute = require('./routes/members/edit')(sequelize); +const membersDeleteRoute = require('./routes/members/delete')(sequelize); +app.use('/members', membersIndexRoute); +app.use('/members', membersNewRoute); +app.use('/members', membersEditRoute); +app.use('/members', membersDeleteRoute); + // "/home" const homeIndexRoute = require('./routes/home/index')(sequelize); app.use('/home', homeIndexRoute); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 48271eb..51e210e 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,8 +1,9 @@ const jwt = require('jsonwebtoken'); -const { roles, defaultDangerKey } = require('../routes/users/authSettings'); +const { roles, memberRoles, defaultDangerKey } = require('../routes/users/authSettings'); const defineProject = require('../models/projects'); const defineUser = require('../models/users'); const { DataTypes } = require('sequelize'); +const defineMember = require('../models/members'); function authMiddleware(sequelize) { /** @@ -97,7 +98,49 @@ function authMiddleware(sequelize) { next(); } - return { verifySignedIn, verifyAdmin, verifyProjectVisible, verifyProjectOwner }; + /** + * Verify user has permission of project management + * (User must be the owner or manager of the project ) + * (have to be called after verifySignedIn() middleware) + */ + async function verifyProjectManager(req, res, next) { + const Project = defineProject(sequelize, DataTypes); + + const projectId = req.params.projectId || req.query.projectId; + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + 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 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'); + if (member.role === managerRoleIndex) { + next(); + return; + } + } + + return res.status(403).json({ error: 'Forbidden' }); + } + + return { verifySignedIn, verifyAdmin, verifyProjectVisible, verifyProjectOwner, verifyProjectManager }; } module.exports = authMiddleware; diff --git a/backend/migrations/20240529221509-create-members.js b/backend/migrations/20240529221509-create-members.js new file mode 100644 index 0000000..a28aff0 --- /dev/null +++ b/backend/migrations/20240529221509-create-members.js @@ -0,0 +1,52 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('members', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + userId: { + type: Sequelize.INTEGER, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + projectId: { + type: Sequelize.INTEGER, + references: { + model: 'projects', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + role: { + type: Sequelize.INTEGER, + allowNull: false, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + await queryInterface.addIndex('members', ['userId', 'projectId'], { + unique: true, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('members'); + }, +}; diff --git a/backend/models/members.js b/backend/models/members.js new file mode 100644 index 0000000..2ecefe2 --- /dev/null +++ b/backend/models/members.js @@ -0,0 +1,31 @@ +function defineMember(sequelize, DataTypes) { + const Member = sequelize.define('Member', { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + projectId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + role: { + type: DataTypes.INTEGER, + allowNull: false, + }, + }); + + Member.associate = (models) => { + Member.belongsTo(models.User, { + foreignKey: 'userId', + onDelete: 'CASCADE', + }); + Member.belongsTo(models.Project, { + foreignKey: 'projectId', + onDelete: 'CASCADE', + }); + }; + + return Member; +} + +module.exports = defineMember; diff --git a/backend/routes/members/delete.js b/backend/routes/members/delete.js new file mode 100644 index 0000000..854e2cf --- /dev/null +++ b/backend/routes/members/delete.js @@ -0,0 +1,36 @@ +const express = require('express'); +const router = express.Router(); +const defineMember = require('../../models/members'); +const { DataTypes } = require('sequelize'); + +module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectManager } = require('../../middleware/auth')(sequelize); + const Member = defineMember(sequelize, DataTypes); + + router.delete('/', verifySignedIn, verifyProjectManager, async (req, res) => { + const userId = req.query.userId; + const projectId = req.query.projectId; + + try { + // Get Member to be deleted. + const deletingMember = await Member.findOne({ + where: { + userId: userId, + projectId: projectId, + }, + }); + + if (!deletingMember) { + return res.status(404).send('Member not found'); + } + + await deletingMember.destroy(); + res.status(204).send(); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +}; diff --git a/backend/routes/members/edit.js b/backend/routes/members/edit.js new file mode 100644 index 0000000..91838ab --- /dev/null +++ b/backend/routes/members/edit.js @@ -0,0 +1,38 @@ +const express = require('express'); +const router = express.Router(); +const defineMember = require('../../models/members'); +const { DataTypes } = require('sequelize'); + +module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectManager } = require('../../middleware/auth')(sequelize); + const Member = defineMember(sequelize, DataTypes); + + router.put('/', verifySignedIn, verifyProjectManager, async (req, res) => { + const userId = req.query.userId; + const projectId = req.query.projectId; + + try { + const member = await Member.findOne({ + where: { + userId: userId, + projectId: projectId, + }, + }); + + if (!member) { + return res.status(404).send('Member not found'); + } + + await member.update({ + userId, + projectId, + }); + res.json(member); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +}; diff --git a/backend/routes/members/index.js b/backend/routes/members/index.js new file mode 100644 index 0000000..36db17e --- /dev/null +++ b/backend/routes/members/index.js @@ -0,0 +1,40 @@ +const express = require('express'); +const router = express.Router(); +const defineMember = require('../../models/members'); +const defineUser = require('../../models/users'); +const { DataTypes } = require('sequelize'); + +module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectManager } = require('../../middleware/auth')(sequelize); + const Member = defineMember(sequelize, DataTypes); + const User = defineUser(sequelize, DataTypes); + + Member.belongsTo(User, { foreignKey: 'userId' }); + + router.get('/', verifySignedIn, verifyProjectManager, async (req, res) => { + const { projectId } = req.query; + + if (!projectId) { + return res.status(400).json({ error: 'projectId is required' }); + } + + try { + const members = await Member.findAll({ + where: { + projectId: projectId, + }, + include: [ + { + model: User, + }, + ], + }); + res.json(members); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +}; diff --git a/backend/routes/members/new.js b/backend/routes/members/new.js new file mode 100644 index 0000000..bfdbf7b --- /dev/null +++ b/backend/routes/members/new.js @@ -0,0 +1,40 @@ +const express = require('express'); +const router = express.Router(); +const defineMember = require('../../models/members'); +const { DataTypes } = require('sequelize'); + +module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectManager } = require('../../middleware/auth')(sequelize); + const Member = defineMember(sequelize, DataTypes); + + router.post('/', verifySignedIn, verifyProjectManager, async (req, res) => { + const userId = req.query.userId; + const projectId = req.query.projectId; + + try { + // Check if the record already exists + const existingMember = await Member.findOne({ + where: { + userId: userId, + projectId: projectId, + }, + }); + + if (existingMember) { + return res.status(400).send('Record already exists'); + } + + const newMember = await Member.create({ + userId: userId, + projectId: projectId, + }); + + res.json(newMember); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +}; diff --git a/backend/routes/users/authSettings.js b/backend/routes/users/authSettings.js index 15c49d4..1947eba 100644 --- a/backend/routes/users/authSettings.js +++ b/backend/routes/users/authSettings.js @@ -1,4 +1,5 @@ const roles = [{ uid: 'administrator' }, { uid: 'user' }]; +const memberRoles = [{ uid: 'manager' }, { uid: 'developer' }, { uid: 'reporter' }]; const defaultDangerKey = 'test-plat-default-key'; -module.exports = { roles, defaultDangerKey }; +module.exports = { roles, memberRoles, defaultDangerKey }; diff --git a/backend/seeders/seed.js b/backend/seeders/seed.js index 3665928..71d6984 100644 --- a/backend/seeders/seed.js +++ b/backend/seeders/seed.js @@ -438,6 +438,23 @@ module.exports = { updatedAt: new Date(), }, ]); + + await queryInterface.bulkInsert('members', [ + { + userId: 1, + projectId: 1, + role: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + userId: 1, + projectId: 2, + role: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); }, down: async (queryInterface, Sequelize) => { diff --git a/frontend/config/selection.ts b/frontend/config/selection.ts index 315f752..1fdda50 100644 --- a/frontend/config/selection.ts +++ b/frontend/config/selection.ts @@ -1,4 +1,5 @@ const roles = [{ uid: 'administrator' }, { uid: 'user' }]; +const memberRoles = [{ uid: 'manager' }, { uid: 'developer' }, { uid: 'reporter' }]; const categoricalPalette = ['#fba91e', '#6ea56c', '#3ac6e1', '#feda2f', '#f15f47', '#244470', '#9c80bb', '#f595a6']; @@ -55,4 +56,4 @@ const testRunCaseStatus = [ { uid: 'skipped', color: 'primary', chartColor: '#805aab' }, ]; -export { roles, priorities, testTypes, automationStatus, templates, testRunStatus, testRunCaseStatus }; +export { roles, memberRoles, priorities, testTypes, automationStatus, templates, testRunStatus, testRunCaseStatus }; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f1b749e..0f7c569 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -82,7 +82,7 @@ "project_detail": "Project Detail", "public": "Public", "private": "Private", - "if_you_make_public": "If you make project public, everyone who has access to this site will be able to view it.", + "if_you_make_public": "Making a project public makes it visible to users who are not project members.", "close": "Close", "create": "Create", "update": "Update", @@ -94,7 +94,8 @@ "Project": { "home": "Home", "test_cases": "Test Cases", - "test_runs": "Test Runs" + "test_runs": "Test Runs", + "settings": "Settings" }, "Home": { "Folders": "Folders", @@ -256,5 +257,16 @@ "include_in_run": "Include in run", "exclude_from_run": "Exclude from run", "no_cases_found": "No cases found" + }, + "Settings": { + "member_management": "Member Management", + "avatar": "Avatar", + "email": "Email", + "username": "User name", + "role": "Role", + "manager": "Manager", + "developer": "Developer", + "reporter": "Reporter", + "no_members_found": "No members found" } } diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index b98b987..7109a8a 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -81,7 +81,7 @@ "project_detail": "プロジェクト詳細", "public": "パブリック", "private": "プライベート", - "if_you_make_public": "プロジェクトをパブリックにすると、このサイトにアクセスできるすべてのユーザーがプロジェクトを見ることができます。", + "if_you_make_public": "プロジェクトをパブリックにすると、プロジェクトメンバーではないユーザーからも見えるようになります。", "close": "閉じる", "create": "作成", "update": "更新", @@ -93,7 +93,8 @@ "Project": { "home": "ホーム", "test_cases": "テストケース", - "test_runs": "テストラン" + "test_runs": "テストラン", + "settings": "設定" }, "Home": { "Folders": "フォルダー", @@ -255,5 +256,16 @@ "include_in_run": "テストランに含める", "exclude_from_run": "テストランから除外する", "no_cases_found": "テストケースが見つかりません" + }, + "Settings": { + "member_management": "メンバー管理", + "avatar": "アバター", + "email": "メールアドレス", + "username": "ユーザー名", + "role": "ロール", + "manager": "管理者", + "developer": "開発者", + "reporter": "報告者", + "no_members_found": "メンバーがいません" } } diff --git a/frontend/src/app/[locale]/projects/[projectId]/Sidebar.tsx b/frontend/src/app/[locale]/projects/[projectId]/Sidebar.tsx index 764bc61..d09b202 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/Sidebar.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/Sidebar.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; import { Listbox, ListboxItem } from '@nextui-org/react'; -import { Home, Files, FlaskConical } from 'lucide-react'; +import { Home, Files, FlaskConical, Settings } from 'lucide-react'; import { usePathname, useRouter } from '@/src/navigation'; import useGetCurrentIds from '@/utils/useGetCurrentIds'; import { ProjectMessages } from '@/types/project'; @@ -27,6 +27,8 @@ export default function Sidebar({ messages, locale }: Props) { router.push(`/projects/${projectId}/folders`, { locale: locale }); } else if (key === 'runs') { router.push(`/projects/${projectId}/runs`, { locale: locale }); + } else if (key === 'settings') { + router.push(`/projects/${projectId}/settings`, { locale: locale }); } }; @@ -38,6 +40,8 @@ export default function Sidebar({ messages, locale }: Props) { setCurrentTab('cases'); } else if (currentPath.includes('runs')) { setCurrentTab('runs'); + } else if (currentPath.includes('settings')) { + setCurrentTab('settings'); } }; @@ -60,6 +64,11 @@ export default function Sidebar({ messages, locale }: Props) { text: messages.testRuns, startContent: , }, + { + key: 'settings', + text: messages.settings, + startContent: , + }, ]; return ( diff --git a/frontend/src/app/[locale]/projects/[projectId]/layout.tsx b/frontend/src/app/[locale]/projects/[projectId]/layout.tsx index 7210964..8066f4e 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/layout.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/layout.tsx @@ -13,6 +13,7 @@ export default function SidebarLayout({ home: t('home'), testCases: t('test_cases'), testRuns: t('test_runs'), + settings: t('settings'), }; return ( diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/MembersTable.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/MembersTable.tsx new file mode 100644 index 0000000..7a5b31a --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/MembersTable.tsx @@ -0,0 +1,108 @@ +import { useState, useMemo, useCallback } from 'react'; +import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, SortDescriptor } from '@nextui-org/react'; +import { UserType } from '@/types/user'; +import { SettingsMessages } from '@/types/settings'; +import { memberRoles } from '@/config/selection'; +import Avatar from 'boring-avatars'; + +type Props = { + members: UserType[]; + messages: SettingsMessages; + locale: string; +}; + +export default function MembersTable({ members, messages, locale }: Props) { + const headerColumns = [ + { name: messages.avatar, uid: 'avatar', sortable: false }, + { name: messages.email, uid: 'email', sortable: true }, + { name: messages.username, uid: 'username', sortable: true }, + { name: messages.role, uid: 'role', sortable: true }, + ]; + + const [sortDescriptor, setSortDescriptor] = useState({ + column: 'role', + direction: 'ascending', + }); + + const sortedItems = useMemo(() => { + return [...members].sort((a: UserType, b: UserType) => { + const first = a[sortDescriptor.column as keyof UserType] as number; + const second = b[sortDescriptor.column as keyof UserType] as number; + const cmp = first < second ? -1 : first > second ? 1 : 0; + + return sortDescriptor.direction === 'descending' ? -cmp : cmp; + }); + }, [sortDescriptor, members]); + + const renderCell = useCallback((member: UserType, columnKey: Key) => { + const cellValue = member[columnKey as keyof UserType]; + + switch (columnKey) { + case 'avatar': + return ( + + ); + case 'email': + return member.User.email; + case 'username': + return member.User.username; + case 'role': + return {messages[memberRoles[cellValue].uid]}; + default: + return cellValue; + } + }, []); + + const classNames = useMemo( + () => ({ + wrapper: ['max-w-3xl'], + th: ['bg-transparent', 'text-default-500', 'border-b', 'border-divider'], + td: [ + // changing the rows border radius + // first + 'group-data-[first=true]:first:before:rounded-none', + 'group-data-[first=true]:last:before:rounded-none', + // middle + 'group-data-[middle=true]:before:rounded-none', + // last + 'group-data-[last=true]:first:before:rounded-none', + 'group-data-[last=true]:last:before:rounded-none', + ], + }), + [] + ); + + return ( + <> + + + {(column) => ( + + {column.name} + + )} + + + {(item) => ( + {(columnKey) => {renderCell(item, columnKey)}} + )} + +
+ + ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx new file mode 100644 index 0000000..e78a390 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/SettingsPage.tsx @@ -0,0 +1,96 @@ +'use client'; +import React from 'react'; +import { useState, useEffect, useContext } from 'react'; +import { UserType } from '@/types/user'; +import { SettingsMessages } from '@/types/settings'; +import { TokenContext } from '@/utils/TokenProvider'; +import MembersTable from './MembersTable'; +import Config from '@/config/config'; +const apiServer = Config.apiServer; + +type Props = { + projectId: string; + messages: SettingsMessages; + locale: string; +}; + +// Member Search +async function fetchProjectMembers(jwt: string, projectId: string) { + const fetchOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }; + + const url = `${apiServer}/members?projectId=${projectId}`; + + try { + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const data = await response.json(); + console.log(data); + return data; + } catch (error: any) { + console.error('Error fetching data:', error.message); + } +} + +// User Search by username +async function fetchUsers(jwt: string, text: string) { + const fetchOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }; + + const url = `${apiServer}/users?text=${text}`; + + try { + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error: any) { + console.error('Error fetching data:', error.message); + } +} + +export default function SettingsPage({ projectId, messages, locale }: Props) { + const context = useContext(TokenContext); + const [members, setMembers] = useState([]); + + useEffect(() => { + async function fetchDataEffect() { + if (!context.isSignedIn()) { + return; + } + + try { + const data = await fetchProjectMembers(context.token.access_token, projectId); + setMembers(data); + } catch (error: any) { + console.error('Error in effect:', error.message); + } + } + + fetchDataEffect(); + }, [context]); + + return ( +
+
+

{messages.memberManagement}

+
+ + +
+ ); +} diff --git a/frontend/src/app/[locale]/projects/[projectId]/settings/page.tsx b/frontend/src/app/[locale]/projects/[projectId]/settings/page.tsx new file mode 100644 index 0000000..f1b5c89 --- /dev/null +++ b/frontend/src/app/[locale]/projects/[projectId]/settings/page.tsx @@ -0,0 +1,23 @@ +import SettingsPage from './SettingsPage'; +import { useTranslations } from 'next-intl'; + +export default function Page({ params }: { params: { projectId: string; locale: string } }) { + const t = useTranslations('Settings'); + const messages = { + memberManagement: t('member_management'), + avatar: t('avatar'), + email: t('email'), + username: t('username'), + role: t('role'), + manager: t('manager'), + developer: t('developer'), + reporter: t('reporter'), + noMembersFound: t('no_members_found'), + }; + + return ( + <> + + + ); +} diff --git a/frontend/types/project.ts b/frontend/types/project.ts index 24ff43c..f62178e 100644 --- a/frontend/types/project.ts +++ b/frontend/types/project.ts @@ -42,4 +42,5 @@ export type ProjectMessages = { home: string; testCases: string; testRuns: string; + settings: string; }; diff --git a/frontend/types/settings.ts b/frontend/types/settings.ts new file mode 100644 index 0000000..a79f38e --- /dev/null +++ b/frontend/types/settings.ts @@ -0,0 +1,8 @@ +export type SettingsMessages = { + memberManagement: string; + avatar: string; + email: string; + username: string; + role: string; + noMembersFound: string; +};