diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 6719dab..8c48215 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,21 +1,82 @@ const jwt = require('jsonwebtoken'); const { defaultDangerKey } = require('../routes/auth/authSettings'); +const defineProject = require('../models/projects'); +const { DataTypes } = require('sequelize'); -function verifySinedIn(req, res, next) { - const token = req.header('Authorization'); - const secretKey = process.env.SECRET_KEY || defaultDangerKey; +function authMiddleware(sequelize) { + /** + * Verify user sined in + * + * If verification is successful, set userId in req.userId. + */ + function verifySignedIn(req, res, next) { + const authHeader = req.header('Authorization'); + const secretKey = process.env.SECRET_KEY || defaultDangerKey; - if (!token) { - return res.status(401).json({ error: 'Access denied' }); + const token = authHeader.split(' ')[1]; // delete 'Bearer ' + if (!token) { + return res.status(401).json({ error: 'Access denied' }); + } + + try { + const decoded = jwt.verify(token, secretKey); + req.userId = decoded.userId; + next(); + } catch (error) { + res.status(401).json({ error: 'Invalid token' }); + } } - try { - const decoded = jwt.verify(token, secretKey); - req.userId = decoded.userId; + /** + * Verify user can access project + * (have to be called after verifySignedIn() middleware) + */ + async function verifyProjectVisible(req, res, next) { + const Project = defineProject(sequelize, DataTypes); + + const projectId = req.params.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 is private, only project owner can access + if (!project.isPublic && project.userId !== req.userId) { + return res.status(403).json({ error: 'Forbidden' }); + } + next(); - } catch (error) { - res.status(401).json({ error: 'Invalid token' }); } + + /** + * Verify user has project + * (have to be called after verifySignedIn() middleware) + */ + async function verifyProjectOwner(req, res, next) { + const Project = defineProject(sequelize, DataTypes); + + const projectId = req.params.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) { + return res.status(403).json({ error: 'Forbidden' }); + } + + next(); + } + + return { verifySignedIn, verifyProjectVisible, verifyProjectOwner }; } -module.exports = { verifySinedIn }; +module.exports = authMiddleware; diff --git a/backend/routes/home/index.js b/backend/routes/home/index.js index 5d19b8b..d44d715 100644 --- a/backend/routes/home/index.js +++ b/backend/routes/home/index.js @@ -8,6 +8,8 @@ const defineRunCase = require('../../models/runCases'); const { DataTypes } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectVisible } = require('../../middleware/auth')(sequelize); + const Project = defineProject(sequelize, DataTypes); const Folder = defineFolder(sequelize, DataTypes); const Case = defineCase(sequelize, DataTypes); @@ -18,7 +20,7 @@ module.exports = function (sequelize) { Project.hasMany(Run, { foreignKey: 'projectId' }); Run.hasMany(RunCase, { foreignKey: 'runId' }); - router.get('/:projectId', async (req, res) => { + router.get('/:projectId', verifySignedIn, verifyProjectVisible, async (req, res) => { const projectId = req.params.projectId; if (!projectId) { diff --git a/backend/routes/projects/delete.js b/backend/routes/projects/delete.js index 3c6a7e5..68f9b5a 100644 --- a/backend/routes/projects/delete.js +++ b/backend/routes/projects/delete.js @@ -4,9 +4,10 @@ const defineProject = require('../../models/projects'); const { DataTypes } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectOwner } = require('../../middleware/auth')(sequelize); const Project = defineProject(sequelize, DataTypes); - router.delete('/:projectId', async (req, res) => { + router.delete('/:projectId', verifySignedIn, verifyProjectOwner, async (req, res) => { const projectId = req.params.projectId; try { const project = await Project.findByPk(projectId); diff --git a/backend/routes/projects/edit.js b/backend/routes/projects/edit.js index a8c2a33..561365d 100644 --- a/backend/routes/projects/edit.js +++ b/backend/routes/projects/edit.js @@ -4,9 +4,10 @@ const defineProject = require('../../models/projects'); const { DataTypes } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectOwner } = require('../../middleware/auth')(sequelize); const Project = defineProject(sequelize, DataTypes); - router.put('/:projectId', async (req, res) => { + router.put('/:projectId', verifySignedIn, verifyProjectOwner, async (req, res) => { const projectId = req.params.projectId; const { name, detail, isPublic } = req.body; try { diff --git a/backend/routes/projects/index.js b/backend/routes/projects/index.js index be3494b..f2ecaf9 100644 --- a/backend/routes/projects/index.js +++ b/backend/routes/projects/index.js @@ -1,15 +1,19 @@ const express = require('express'); const router = express.Router(); const defineProject = require('../../models/projects'); -const { DataTypes } = require('sequelize'); -const { verifySinedIn } = require('../../middleware/auth'); +const { DataTypes, Op } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn } = require('../../middleware/auth')(sequelize); const Project = defineProject(sequelize, DataTypes); - router.get('/', verifySinedIn, async (req, res) => { + router.get('/', verifySignedIn, async (req, res) => { try { - const projects = await Project.findAll(); + const projects = await Project.findAll({ + where: { + [Op.or]: [{ isPublic: true }, { userId: req.userId }], + }, + }); res.json(projects); } catch (error) { console.error(error); diff --git a/backend/routes/projects/new.js b/backend/routes/projects/new.js index 503bdd3..74296fc 100644 --- a/backend/routes/projects/new.js +++ b/backend/routes/projects/new.js @@ -4,16 +4,17 @@ const defineProject = require('../../models/projects'); const { DataTypes } = require('sequelize'); module.exports = function (sequelize) { + const { verifySignedIn } = require('../../middleware/auth')(sequelize); const Project = defineProject(sequelize, DataTypes); - router.post('/', async (req, res) => { + router.post('/', verifySignedIn, async (req, res) => { try { - const { name, detail, isPublic, userId } = req.body; + const { name, detail, isPublic } = req.body; const newProject = await Project.create({ name, detail, isPublic, - userId, + userId: req.userId, }); res.json(newProject); } catch (error) { diff --git a/backend/routes/projects/show.js b/backend/routes/projects/show.js index f5e8218..951cb18 100644 --- a/backend/routes/projects/show.js +++ b/backend/routes/projects/show.js @@ -3,25 +3,19 @@ const router = express.Router(); const defineProject = require('../../models/projects'); const defineFolder = require('../../models/folders'); const { DataTypes } = require('sequelize'); -const { verifySinedIn } = require('../../middleware/auth'); module.exports = function (sequelize) { + const { verifySignedIn, verifyProjectVisible } = require('../../middleware/auth')(sequelize); const Project = defineProject(sequelize, DataTypes); const Folder = defineFolder(sequelize, DataTypes); Project.hasMany(Folder, { foreignKey: 'projectId' }); - router.get('/:projectId', verifySinedIn, async (req, res) => { + router.get('/:projectId', verifySignedIn, verifyProjectVisible, async (req, res) => { const projectId = req.params.projectId; - if (!projectId) { return res.status(400).json({ error: 'projectId is required' }); } - // if project is private, only project owner can access - if (!project.isPublic && project.userId !== req.userId) { - return res.status(403).json({ error: 'Forbidden' }); - } - try { const project = await Project.findByPk(projectId, { include: [ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 34849dc..ca09646 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -65,6 +65,7 @@ "project_name": "Project Name", "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.", "close": "Close", "create": "Create", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 9ebf022..d869e25 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -64,6 +64,7 @@ "project_name": "プロジェクト名", "project_detail": "プロジェクト詳細", "public": "パブリック", + "private": "プライベート", "if_you_make_public": "プロジェクトをパブリックにすると、このサイトにアクセスできるすべてのユーザーがプロジェクトを見ることができます。", "close": "閉じる", "create": "作成", diff --git a/frontend/src/app/[locale]/TokenProvider.tsx b/frontend/src/app/[locale]/TokenProvider.tsx index f5504c9..e71999e 100644 --- a/frontend/src/app/[locale]/TokenProvider.tsx +++ b/frontend/src/app/[locale]/TokenProvider.tsx @@ -4,7 +4,6 @@ import { TokenContextType, TokenType } from '@/types/user'; import { TokenProps } from '@/types/user'; import { useRouter, usePathname } from '@/src/navigation'; const LOCAL_STORAGE_KEY = 'testplat-auth-token'; -const privatePaths = ['/account', '/projects']; function storeTokenToLocalStorage(token: TokenType) { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(token)); @@ -43,6 +42,8 @@ const TokenProvider = ({ locale, children }: TokenProps) => { // check expire date if (Date.now() < token.expires_at) { return true; + } else { + console.error('session expired'); } } @@ -71,17 +72,16 @@ const TokenProvider = ({ locale, children }: TokenProps) => { }, []); useEffect(() => { - const isPrivatePath = () => { - return privatePaths.some((path) => { - return path === pathname; - }); + // check current path is private. pravate path is '/account/*' or '/projects/*' + const isPrivatePath = (pathname: string) => { + return /^\/(account|projects)\/.*/.test(pathname); }; const checkSignInPage = () => { if (!hasRestoreFinished) { return; } - if (isPrivatePath() && !isSignedIn()) { + if (isPrivatePath(pathname) && !isSignedIn()) { router.push(`/account/signin`, { locale: locale }); } }; diff --git a/frontend/src/app/[locale]/projects/ProjectsPage.tsx b/frontend/src/app/[locale]/projects/ProjectsPage.tsx index 3323136..4205c2f 100644 --- a/frontend/src/app/[locale]/projects/ProjectsPage.tsx +++ b/frontend/src/app/[locale]/projects/ProjectsPage.tsx @@ -48,11 +48,11 @@ export default function ProjectsPage({ messages, locale }: Props) { const onSubmit = async (name: string, detail: string, isPublic: boolean) => { if (editingProject) { - const updatedProject = await updateProject(editingProject.id, name, detail, isPublic); + const updatedProject = await updateProject(context.token.access_token, editingProject.id, name, detail, isPublic); const updatedProjects = projects.map((project) => (project.id === updatedProject.id ? updatedProject : project)); setProjects(updatedProjects); } else { - const newProject = await createProject(name, detail, isPublic, context.token.user.id); + const newProject = await createProject(context.token.access_token, name, detail, isPublic); setProjects([...projects, newProject]); } closeDialog(); @@ -64,12 +64,11 @@ export default function ProjectsPage({ messages, locale }: Props) { }; const onDeleteClick = async (projectId: number) => { - try { - await deleteProject(projectId); - setProjects(projects.filter((project) => project.id !== projectId)); - } catch (error: any) { - console.error('Error deleting project:', error); - } + // TODO cannot refer context + console.log(context); + console.log(context.token.access_token); + await deleteProject(context.token.access_token, projectId); + setProjects(projects.filter((project) => project.id !== projectId)); }; return ( diff --git a/frontend/src/app/[locale]/projects/ProjectsTable.tsx b/frontend/src/app/[locale]/projects/ProjectsTable.tsx index 8283c63..6330398 100644 --- a/frontend/src/app/[locale]/projects/ProjectsTable.tsx +++ b/frontend/src/app/[locale]/projects/ProjectsTable.tsx @@ -62,7 +62,15 @@ export default function ProjectsTable({ projects, onEditProject, onDeleteProject case 'id': return {cellValue}; case 'isPublic': - return cellValue ? {messages.public} : <>; + return cellValue ? ( + + {messages.public} + + ) : ( + + {messages.private} + + ); case 'name': const maxLength = 30; const truncatedDetail = truncateText(project.detail, maxLength); diff --git a/frontend/src/app/[locale]/projects/[projectId]/home/home.tsx b/frontend/src/app/[locale]/projects/[projectId]/home/home.tsx index 243324c..540c543 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/home/home.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/home/home.tsx @@ -1,12 +1,12 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { Divider } from '@nextui-org/react'; +import { useState, useEffect, useContext } from 'react'; import { title, subtitle } from '@/components/primitives'; -import { Card, CardBody, Chip } from '@nextui-org/react'; +import { Card, CardBody, Chip, Divider } from '@nextui-org/react'; import { Folder, Clipboard, FlaskConical } from 'lucide-react'; import { CaseTypeCountType, CasePriorityCountType } from '@/types/case'; import { ProgressSeriesType } from '@/types/run'; import { HomeMessages } from './page'; +import { TokenContext } from '@/src/app/[locale]/TokenProvider'; import { aggregateBasicInfo, aggregateTestPriority, aggregateTestType, aggregateProgress } from './aggregate'; import TestTypesChart from './TestTypesDonutChart'; import TestPriorityChart from './TestPriorityDonutChart'; @@ -15,19 +15,22 @@ import Config from '@/config/config'; import { useTheme } from 'next-themes'; const apiServer = Config.apiServer; -async function fetchProject(url) { - try { - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); +async function fetchProject(jwt: string, projectId: number) { + const fetchOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: jwt, + }, + }; + const url = `${apiServer}/home/${projectId}`; + + 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) { @@ -41,6 +44,7 @@ type Props = { }; export function Home({ projectId, messages }: Props) { + const context = useContext(TokenContext); const { theme, setTheme } = useTheme(); const [project, setProject] = useState({ name: '', @@ -55,12 +59,15 @@ export function Home({ projectId, messages }: Props) { const [priorityCounts, setPriorityCounts] = useState(); const [progressCategories, setProgressCategories] = useState(); const [progressSeries, setProgressSeries] = useState(); - const url = `${apiServer}/home/${projectId}`; useEffect(() => { async function fetchDataEffect() { + if (!context.isSignedIn()) { + return; + } + try { - const data = await fetchProject(url); + const data = await fetchProject(context.token.access_token, projectId); setProject(data); } catch (error: any) { console.error('Error in effect:', error.message); @@ -68,7 +75,7 @@ export function Home({ projectId, messages }: Props) { } fetchDataEffect(); - }, [url]); + }, [context]); useEffect(() => { async function aggregate() { diff --git a/frontend/src/app/[locale]/projects/page.tsx b/frontend/src/app/[locale]/projects/page.tsx index 6ede5e7..4ce14ae 100644 --- a/frontend/src/app/[locale]/projects/page.tsx +++ b/frontend/src/app/[locale]/projects/page.tsx @@ -18,6 +18,7 @@ export default function Page(params: { locale: string }) { projectName: t('project_name'), projectDetail: t('project_detail'), public: t('public'), + private: t('private'), ifYouMakePublic: t('if_you_make_public'), close: t('close'), create: t('create'), diff --git a/frontend/src/app/[locale]/projects/projectsControl.ts b/frontend/src/app/[locale]/projects/projectsControl.ts index 321d9cd..43764fd 100644 --- a/frontend/src/app/[locale]/projects/projectsControl.ts +++ b/frontend/src/app/[locale]/projects/projectsControl.ts @@ -12,7 +12,7 @@ async function fetchProjects(jwt: string) { method: 'GET', headers: { 'Content-Type': 'application/json', - Authorization: jwt, + Authorization: `Bearer ${jwt}`, }, }); @@ -30,18 +30,18 @@ async function fetchProjects(jwt: string) { /** * Create project */ -async function createProject(name: string, detail: string, isPublic: boolean, userId: number) { +async function createProject(jwt: string, name: string, detail: string, isPublic: boolean) { const newProjectData = { name, detail, isPublic, - userId, }; const fetchOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, }, body: JSON.stringify(newProjectData), }; @@ -64,7 +64,7 @@ async function createProject(name: string, detail: string, isPublic: boolean, us /** * Update project */ -async function updateProject(projectId: number, name: string, detail: string, isPublic: boolean) { +async function updateProject(jwt: string, projectId: number, name: string, detail: string, isPublic: boolean) { const updatedProjectData = { name, detail, @@ -75,6 +75,7 @@ async function updateProject(projectId: number, name: string, detail: string, is method: 'PUT', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, }, body: JSON.stringify(updatedProjectData), }; @@ -97,13 +98,15 @@ async function updateProject(projectId: number, name: string, detail: string, is /** * Delete project */ -async function deleteProject(projectId: number) { +async function deleteProject(jwt: string, projectId: number) { const fetchOptions = { method: 'DELETE', headers: { 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, }, }; + console.log(jwt); const url = `${apiServer}/projects/${projectId}`; diff --git a/frontend/types/project.ts b/frontend/types/project.ts index 4dc4177..1e93e30 100644 --- a/frontend/types/project.ts +++ b/frontend/types/project.ts @@ -27,6 +27,7 @@ export type ProjectsMessages = { projectName: string; projectDetail: string; public: string; + private: string; ifYouMakePublic: string; close: string; create: string;