From 1e20d9b03764fdf593f9baac1de96d83e27caf89 Mon Sep 17 00:00:00 2001 From: Richy Teas Date: Mon, 15 Dec 2025 20:01:29 -0800 Subject: [PATCH 1/4] adding middleware auths --- backend/app.js | 16 +- backend/config/auth.config.js | 8 +- backend/controllers/user.controller.js | 31 +++- backend/middleware/auth.middleware.js | 142 +++++++++++++++++- backend/models/index.js | 4 +- backend/models/refreshToken.model.js | 42 ++++++ backend/models/user.model.js | 20 +-- backend/routers/auth.router.js | 13 +- backend/server.js | 22 ++- .../test/old-tests/projects.router.test.js | 117 ++++++--------- backend/workers/tokenCleanup.js | 17 +++ client/src/components/auth/HandleAuth.jsx | 1 + client/src/context/authContext.jsx | 11 +- client/src/utils/authUtils.js | 3 +- shared/authorizationUtils.js | 39 +++++ shared/roles.js | 17 +++ 16 files changed, 387 insertions(+), 116 deletions(-) create mode 100644 backend/models/refreshToken.model.js create mode 100644 backend/workers/tokenCleanup.js create mode 100644 shared/authorizationUtils.js create mode 100644 shared/roles.js diff --git a/backend/app.js b/backend/app.js index af91fb691..d489c9aa0 100644 --- a/backend/app.js +++ b/backend/app.js @@ -2,8 +2,8 @@ // Load in all of our node modules. Their uses are explained below as they are called. const express = require('express'); -const cron = require('node-cron'); -const fetch = require('node-fetch'); +// const cron = require('node-cron'); +// const fetch = require('node-fetch'); const morgan = require('morgan'); const cookieParser = require('cookie-parser'); @@ -52,14 +52,18 @@ app.use(cookieParser()); app.use(morgan('dev')); // WORKERS -const runOpenCheckinWorker = require('./workers/openCheckins')(cron, fetch); -const runCloseCheckinWorker = require('./workers/closeCheckins')(cron, fetch); +// const runOpenCheckinWorker = require('./workers/openCheckins')(cron, fetch); +// const runCloseCheckinWorker = require('./workers/closeCheckins')(cron, fetch); -const { createRecurringEvents } = require('./workers/createRecurringEvents'); -const runCreateRecurringEventsWorker = createRecurringEvents(cron, fetch); +// const { createRecurringEvents } = require('./workers/createRecurringEvents'); +// const runCreateRecurringEventsWorker = createRecurringEvents(cron, fetch); // const runSlackBot = require("./workers/slackbot")(fetch); +// Run cleanup expired refresh token(s) on startup +const { cleanupExpiredTokens } = require('./workers/tokenCleanup'); +cleanupExpiredTokens(); + // MIDDLEWARE const errorhandler = require('./middleware/errorhandler.middleware'); diff --git a/backend/config/auth.config.js b/backend/config/auth.config.js index fb968cd21..871ba4cad 100644 --- a/backend/config/auth.config.js +++ b/backend/config/auth.config.js @@ -1,8 +1,10 @@ -/*eslint-disable */ + module.exports = { SECRET: 'c0d7d0716e4cecffe9dcc77ff90476d98f5aace08ea40f5516bd982b06401021191f0f24cd6759f7d8ca41b64f68d0b3ad19417453bddfd1dbe8fcb197245079', CUSTOM_REQUEST_HEADER: process.env.CUSTOM_REQUEST_HEADER, - TOKEN_EXPIRATION_SEC: 900, + ACCESS_TOKEN_EXPIRATION_SEC: 900, + // 30 days + REFRESH_TOKEN_EXPIRATION_MS: 30 * 24 * 60 * 60 * 1000, }; -/* eslint-enable */ \ No newline at end of file + diff --git a/backend/controllers/user.controller.js b/backend/controllers/user.controller.js index 70fedc8e0..c9dd0c402 100644 --- a/backend/controllers/user.controller.js +++ b/backend/controllers/user.controller.js @@ -4,7 +4,8 @@ const { ObjectId } = require('mongodb'); const EmailController = require('./email.controller'); const { CONFIG_AUTH } = require('../config'); -const { User, Project } = require('../models'); +const { User, Project, RefreshToken } = require('../models'); +const { generateRefreshToken, getClientIp, hashToken } = require('../middleware/auth.middleware'); const expectedHeader = process.env.CUSTOM_REQUEST_HEADER; @@ -199,7 +200,7 @@ function generateAccessToken(user, auth_origin) { { id: user.id, role: user.accessLevel, auth_origin: auth_origin }, CONFIG_AUTH.SECRET, { - expiresIn: `${CONFIG_AUTH.TOKEN_EXPIRATION_SEC}s`, + expiresIn: `${CONFIG_AUTH.ACCESS_TOKEN_EXPIRATION_SEC}s`, }, ); } @@ -267,7 +268,22 @@ UserController.verifySignIn = async function (req, res) { try { const payload = jwt.verify(token, CONFIG_AUTH.SECRET); const user = await User.findById(payload.id); - res.cookie('token', token, { httpOnly: true }); + const refreshToken = generateRefreshToken(); + const accessToken = generateAccessToken(user, payload.auth_origin); + const ipAddress = getClientIp(req); + + await RefreshToken.create({ + userId: user._id, + hash: hashToken(refreshToken), + deviceInfo: { + deviceType: req.headers['user-agent'], + ipAddress: ipAddress, + }, + }); + + res.cookie('token', accessToken, { httpOnly: true }); + res.cookie('refresh_token', refreshToken, { httpOnly: true }); + return res.send(user); } catch (err) { console.error(err); @@ -281,9 +297,18 @@ UserController.verifyMe = async function (req, res) { }; UserController.logout = async function (req, res) { + await RefreshToken.deleteOne({ id: req.refreshToken.doc._id }); return res.clearCookie('token').status(200).send('Successfully logged out.'); }; +UserController.refreshAccessToken = async function (req, res) { + const accessToken = generateAccessToken(req.user, req.auth_origin); + return res + .cookie('token', accessToken, { httpOnly: true }) + .status(200) + .send('Access token refreshed.'); +}; + // Update user's managedProjects UserController.updateManagedProjects = async function (req, res) { const { headers } = req; diff --git a/backend/middleware/auth.middleware.js b/backend/middleware/auth.middleware.js index f10869183..16082207d 100644 --- a/backend/middleware/auth.middleware.js +++ b/backend/middleware/auth.middleware.js @@ -1,9 +1,136 @@ const jwt = require('jsonwebtoken'); const { CONFIG_AUTH } = require('../config'); +const { RefreshToken, User } = require('../models'); +const crypto = require('crypto'); + +const { hasAnyRole } = require('../../shared/authorizationUtils'); + +const SECRET_KEY = process.env.JWT_SECRET; + +// Utility functions + +function generateAccessToken(user) { + return jwt.sign( + { + user_id: user._id, + email: user.email, + accessLevel: user.accessLevel, + }, + SECRET_KEY, + { expiresIn: '30m' }, + ); +} + +function generateRefreshToken() { + return crypto.randomBytes(32).toString('hex'); +} + +function hashToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +function getClientIp(req) { + // Check X-Forwarded-For header (most common) + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + // Takes the first IP if there are multiple + return forwarded.split(',')[0].trim(); + } + + // Check other common headers + return ( + req.headers['x-real-ip'] || req.connection.remoteAddress || req.socket.remoteAddress || req.ip + ); +} + +async function authenticateAccessToken(req, res, next) { + try { + // Extract token from Authorization header + const authHeader = + req.cookies.token || req.headers['x-access-token'] || req.headers['authorization']; + const token = authHeader?.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Access token required' }); + } + + const decoded = jwt.verify(token, SECRET_KEY); + // Attach user info to request + req.user = decoded; + + next(); + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired' }); + } + + if (error.name === 'JsonWebTokenError') { + return res.status(401).json({ error: 'Invalid token' }); + } + + return res.status(401).json({ error: 'Authentication failed' }); + } +} + +// shorthand for authenticateAccessToken +const authenticate = authenticateAccessToken; + +async function authenticateRefreshToken(req, res, next) { + try { + const refreshToken = req.cookies?.refresh_token; + + if (!refreshToken) { + return res.status(401).json({ error: 'Refresh token required' }); + } + + const tokenHash = hashToken(refreshToken); + + const tokenDoc = await RefreshToken.findOne({ + hash: tokenHash, + expiresAt: { $gt: new Date() }, + }); + + if (!tokenDoc) { + return res.status(401).json({ error: 'Invalid or expired refresh token' }); + } + + const user = await User.findById(tokenDoc.userId); + if (!user) { + return res.status(401).json({ error: 'User not found for this token' }); + } + + // Attach user to request + req.user = user; + + next(); + } catch (error) { + console.error('Refresh token validation error:', error); + return res.status(401).json({ error: 'Authentication failed' }); + } +} + +function requireRole(...roles) { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + if (!hasAnyRole(req.user, roles)) { + return res.status(403).json({ + error: 'Insufficient permissions', + required_role: roles, + your_role: req.user.role, + }); + } + + next(); + }; +} + function verifyToken(req, res, next) { // Allow users to set token - // eslint-disable-next-line dot-notation + let token = req.headers['x-access-token'] || req.headers['authorization']; if (token.startsWith('Bearer ')) { // Remove Bearer from string @@ -19,7 +146,7 @@ function verifyToken(req, res, next) { req.userId = decoded.id; return next(); } catch (err) { - return res.sendStatus(401); + if (err) return res.sendStatus(401); } } @@ -35,8 +162,15 @@ function verifyCookie(req, res, next) { }); } -const AuthUtil = { +module.exports = { + authenticateAccessToken, + authenticate, + authenticateRefreshToken, + requireRole, + generateAccessToken, + generateRefreshToken, + getClientIp, + hashToken, verifyToken, verifyCookie, }; -module.exports = AuthUtil; diff --git a/backend/models/index.js b/backend/models/index.js index 5d4e41315..d9e3752cd 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -6,8 +6,9 @@ const { Question } = require('./question.model'); const { RecurringEvent } = require('./recurringEvent.model'); const { Role } = require('./role.model'); const { User } = require('./user.model'); +const { RefreshToken } = require('./refreshToken.model'); -const mongoose = require("mongoose"); +const mongoose = require('mongoose'); mongoose.Promise = global.Promise; module.exports = { @@ -19,4 +20,5 @@ module.exports = { RecurringEvent, Role, User, + RefreshToken, }; diff --git a/backend/models/refreshToken.model.js b/backend/models/refreshToken.model.js new file mode 100644 index 000000000..e4b5bb41d --- /dev/null +++ b/backend/models/refreshToken.model.js @@ -0,0 +1,42 @@ +const mongoose = require('mongoose'); +const { CONFIG_AUTH } = require('../config'); + +mongoose.Promise = global.Promise; + +const refreshTokenSchema = mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + immutable: true, + index: true, + }, + hash: { type: String, required: true, unique: true, immutable: true }, + createdAt: { type: Date, required: true, default: Date.now(), immutable: true }, + expiresAt: { + type: Date, + required: true, + default: Date.now() + CONFIG_AUTH.REFRESH_TOKEN_EXPIRATION_MS, + }, + deviceInfo: { + ipAddress: String, + deviceType: String, + }, +}); + +refreshTokenSchema.methods.serialize = function () { + return { + id: this._id, + createdAt: this.createdAt, + expiresAt: this.expiresAt, + deviceInfo: { + ipAddress: this.ipAddress, + deviceType: this.deviceType, + }, + }; +}; + +refreshTokenSchema.index({ expires_at: 1 }, { expiresAfterSeconds: 0 }); + +const RefreshToken = mongoose.model('RefreshToken', refreshTokenSchema); + +module.exports = { RefreshToken }; diff --git a/backend/models/user.model.js b/backend/models/user.model.js index 99a64d1fa..f24edc769 100644 --- a/backend/models/user.model.js +++ b/backend/models/user.model.js @@ -1,4 +1,4 @@ -const mongoose = require("mongoose"); +const mongoose = require('mongoose'); // const bcrypt = require('bcrypt-nodejs'); mongoose.Promise = global.Promise; @@ -9,11 +9,12 @@ const userSchema = mongoose.Schema({ lastName: { type: String }, }, email: { type: String, unique: true, lowercase: true }, - accessLevel: { - type: String, - enum: ["user", "admin", "superadmin"], // restricts values to "user", "admin" and "superadmin" - default: "user" + accessLevel: { + type: String, + enum: ['user', 'admin', 'superadmin'], // restricts values to "user", "admin" and "superadmin" + default: 'user', }, + role: { type: String }, createdDate: { type: Date, default: Date.now }, currentRole: { type: String }, // will remove but need to update check-in form desiredRole: { type: String }, // will remove but need to update check-in form @@ -23,11 +24,10 @@ const userSchema = mongoose.Schema({ skillsToMatch: [{ type: String }], // skills the user either has or wants to learn - will use to match to projects firstAttended: { type: String }, attendanceReason: { type: String }, - githubHandle: { type: String }, projects: [ { type: mongoose.Schema.Types.ObjectId, - ref: "Project", + ref: 'Project', }, ], githubHandle: { type: String }, // handle not including @, not the URL @@ -40,7 +40,7 @@ const userSchema = mongoose.Schema({ managedProjects: [{ type: String }], // Which projects managed by user. //currentProject: { type: String } // no longer need this as we can get it from Project Team Member table // password: { type: String, required: true } - isActive: { type: Boolean, default: true } + isActive: { type: Boolean, default: true }, }); userSchema.methods.serialize = function () { @@ -71,10 +71,10 @@ userSchema.methods.serialize = function () { githubPublic2FA: this.githubPublic2FA, availability: this.availability, managedProjects: this.managedProjects, - isActive: this.isActive + isActive: this.isActive, }; }; -const User = mongoose.model("User", userSchema); +const User = mongoose.model('User', userSchema); module.exports = { User }; diff --git a/backend/routers/auth.router.js b/backend/routers/auth.router.js index 4eb83b5f8..4548db0f6 100644 --- a/backend/routers/auth.router.js +++ b/backend/routers/auth.router.js @@ -2,10 +2,11 @@ const express = require('express'); const { AuthUtil, verifyUser, verifyToken } = require('../middleware'); const { UserController } = require('../controllers/'); const { authApiValidator } = require('../validators'); +const { authenticateRefreshToken } = require('../middleware/auth.middleware'); const router = express.Router(); -// eslint-disable-next-line func-names + router.use(function (req, res, next) { res.header('Access-Control-Allow-Headers', 'x-access-token, Origin, Content-Type, Accept'); next(); @@ -18,16 +19,14 @@ router.post( UserController.createUser, ); -router.post( - '/signin', - [authApiValidator.validateSigninUserAPICall, verifyUser.isAdminByEmail], - UserController.signin, -); +router.post('/refresh-access-token', [authenticateRefreshToken], UserController.refreshAccessToken); + +router.post('/signin', [authApiValidator.validateSigninUserAPICall], UserController.signin); router.post('/verify-signin', [verifyToken.isTokenValid], UserController.verifySignIn); router.post('/me', [AuthUtil.verifyCookie], UserController.verifyMe); -router.post('/logout', [AuthUtil.verifyCookie], UserController.logout); +router.post('/logout', [authenticateRefreshToken], UserController.logout); module.exports = router; diff --git a/backend/server.js b/backend/server.js index 2d4bb9604..0877619b9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,7 +1,7 @@ -const app = require("./app"); -const mongoose = require("mongoose"); +const app = require('./app'); +const mongoose = require('mongoose'); -const { Role } = require("./models"); +const { Role } = require('./models'); // Load config variables const { CONFIG_DB } = require('./config/'); @@ -22,11 +22,9 @@ async function runServer(databaseUrl = CONFIG_DB.DATABASE_URL, port = CONFIG_DB. server = app .listen(port, () => { - console.log( - `Mongoose connected from runServer() and is listening on ${port}` - ); + console.log(`Mongoose connected from runServer() and is listening on ${port}`); }) - .on("error", (err) => { + .on('error', (err) => { mongoose.disconnect(); return err; }); @@ -35,7 +33,7 @@ async function runServer(databaseUrl = CONFIG_DB.DATABASE_URL, port = CONFIG_DB. async function closeServer() { await mongoose.disconnect().then(() => { return new Promise((resolve, reject) => { - console.log("Closing Mongoose connection. Bye"); + console.log('Closing Mongoose connection. Bye'); server.close((err) => { if (err) { @@ -52,20 +50,20 @@ function initial() { Role.collection.estimatedDocumentCount((err, count) => { if (!err && count === 0) { new Role({ - name: "APP_USER", + name: 'APP_USER', }).save((err) => { if (err) { - console.log("error", err); + console.log('error', err); } console.log("added 'user' to roles collection"); }); new Role({ - name: "APP_ADMIN", + name: 'APP_ADMIN', }).save((err) => { if (err) { - console.log("error", err); + console.log('error', err); } console.log("added 'moderator' to roles collection"); diff --git a/backend/test/old-tests/projects.router.test.js b/backend/test/old-tests/projects.router.test.js index e7ebf7129..896954f5c 100644 --- a/backend/test/old-tests/projects.router.test.js +++ b/backend/test/old-tests/projects.router.test.js @@ -4,11 +4,10 @@ const request = supertest(app); const jwt = require('jsonwebtoken'); const { CONFIG_AUTH } = require('../config'); - const { setupDB } = require('../setup-test'); setupDB('api-projects'); -const { Project, User } = require('../models'); +const { User } = require('../models'); const CONFIG = require('../config/auth.config'); const headers = {}; @@ -18,9 +17,8 @@ headers.authorization = 'Bearer sometoken'; let token; - describe('CREATE', () => { - beforeAll( async () => { + beforeAll(async () => { const submittedData = { name: { firstName: 'test', @@ -30,14 +28,10 @@ describe('CREATE', () => { }; const user = await User.create(submittedData); const auth_origin = 'TEST'; - token = jwt.sign( - { id: user.id, role: user.accessLevel, auth_origin }, - CONFIG_AUTH.SECRET, - { - expiresIn: `${CONFIG_AUTH.TOKEN_EXPIRATION_SEC}s`, - }, - ); - }) + token = jwt.sign({ id: user.id, role: user.accessLevel, auth_origin }, CONFIG_AUTH.SECRET, { + expiresIn: `${CONFIG_AUTH.ACCESS_TOKEN_EXPIRATION_SEC}s`, + }); + }); test('Create a Project with POST to /api/projects/ without token', async (done) => { // Test Data const submittedData = { @@ -45,10 +39,7 @@ describe('CREATE', () => { }; // Submit a project - const res = await request - .post('/api/projects/') - .set(headers) - .send(submittedData); + const res = await request.post('/api/projects/').set(headers).send(submittedData); expect(res.status).toBe(401); done(); }); @@ -63,7 +54,7 @@ describe('CREATE', () => { const res = await request .post('/api/projects/') .set(headers) - .set('Cookie', [`token=${token}`] ) + .set('Cookie', [`token=${token}`]) .send(submittedData); expect(res.status).toBe(201); done(); @@ -72,27 +63,27 @@ describe('CREATE', () => { describe('READ', () => { test('Get all projects with GET to /api/projects/', async (done) => { - // Test Data - const submittedData = { - name: 'projectName', - }; - - // Submit a project - const res = await request - .post('/api/projects/') - .set(headers) - .set('Cookie', [`token=${token}`]) - .send(submittedData); - expect(res.status).toBe(201); - - // Get all projects - const res2 = await request.get('/api/projects/').set(headers); - expect(res2.status).toBe(200); - - const APIData = res2.body[0]; - expect(APIData.name).toBe(submittedData.name); - done(); - });; + // Test Data + const submittedData = { + name: 'projectName', + }; + + // Submit a project + const res = await request + .post('/api/projects/') + .set(headers) + .set('Cookie', [`token=${token}`]) + .send(submittedData); + expect(res.status).toBe(201); + + // Get all projects + const res2 = await request.get('/api/projects/').set(headers); + expect(res2.status).toBe(200); + + const APIData = res2.body[0]; + expect(APIData.name).toBe(submittedData.name); + done(); + }); }); describe('UPDATE', () => { @@ -106,14 +97,10 @@ describe('UPDATE', () => { }; const user = await User.create(submittedData); const auth_origin = 'TEST'; - token = jwt.sign( - { id: user.id, role: user.accessLevel, auth_origin }, - CONFIG_AUTH.SECRET, - { - expiresIn: `${CONFIG_AUTH.TOKEN_EXPIRATION_SEC}s`, - }, - ); - }) + token = jwt.sign({ id: user.id, role: user.accessLevel, auth_origin }, CONFIG_AUTH.SECRET, { + expiresIn: `${CONFIG_AUTH.ACCESS_TOKEN_EXPIRATION_SEC}s`, + }); + }); test('Update a project with PATCH to /api/projects/:id without a token', async (done) => { // Test Data const submittedData = { @@ -140,8 +127,7 @@ describe('UPDATE', () => { expect(res2.status).toBe(401); // Get project - const res3 = await request.get(`/api/projects/${res.body._id}`) - .set(headers); + const res3 = await request.get(`/api/projects/${res.body._id}`).set(headers); expect(res3.status).toBe(200); done(); }); @@ -169,12 +155,13 @@ describe('UPDATE', () => { .set(headers) .set('Cookie', [`token=${token}`]) .send(updatedDataPayload); - expect(res2.status).toBe(200) + expect(res2.status).toBe(200); // Get project - const res3 = await request.get(`/api/projects/${res.body._id}`) - .set(headers) - .set('Cookie', [`token=${token}`]) + const res3 = await request + .get(`/api/projects/${res.body._id}`) + .set(headers) + .set('Cookie', [`token=${token}`]); expect(res3.status).toBe(200); const APIData = res3.body; @@ -194,14 +181,10 @@ describe('DELETE', () => { }; const user = await User.create(submittedData); const auth_origin = 'TEST'; - token = jwt.sign( - { id: user.id, role: user.accessLevel, auth_origin }, - CONFIG_AUTH.SECRET, - { - expiresIn: `${CONFIG_AUTH.TOKEN_EXPIRATION_SEC}s`, - }, - ); - }) + token = jwt.sign({ id: user.id, role: user.accessLevel, auth_origin }, CONFIG_AUTH.SECRET, { + expiresIn: `${CONFIG_AUTH.ACCESS_TOKEN_EXPIRATION_SEC}s`, + }); + }); test('Delete a project with POST to /api/projects/:id without a token', async (done) => { // Test Data const submittedData = { @@ -217,11 +200,10 @@ describe('DELETE', () => { expect(res.status).toBe(201); // Delete project - const res2 = await request.patch(`/api/projects/${res.body._id}`) - .set(headers); + const res2 = await request.patch(`/api/projects/${res.body._id}`).set(headers); expect(res2.status).toBe(401); done(); -}); + }); test('Delete a project with POST to /api/projects/:id with a token', async (done) => { // Test Data const submittedData = { @@ -237,10 +219,11 @@ describe('DELETE', () => { expect(res.status).toBe(201); // Delete project - const res2 = await request.patch(`/api/projects/${res.body._id}`) - .set(headers) - .set('Cookie', [`token=${token}`]) + const res2 = await request + .patch(`/api/projects/${res.body._id}`) + .set(headers) + .set('Cookie', [`token=${token}`]); expect(res2.status).toBe(200); done(); -}); + }); }); diff --git a/backend/workers/tokenCleanup.js b/backend/workers/tokenCleanup.js new file mode 100644 index 000000000..e4c98917d --- /dev/null +++ b/backend/workers/tokenCleanup.js @@ -0,0 +1,17 @@ +const { RefreshToken } = require('../models'); + +async function cleanupExpiredTokens() { + try { + const result = await RefreshToken.deleteMany({ + expiresAt: { $lt: new Date() }, + }); + console.log(`Cleaned up ${result.deletedCount} expired refresh tokens`); + } catch (err) { + console.error('Token cleanup error:', err); + } +} + +// Run daily +setInterval(cleanupExpiredTokens, 24 * 60 * 60 * 1000); + +module.exports = { cleanupExpiredTokens }; diff --git a/client/src/components/auth/HandleAuth.jsx b/client/src/components/auth/HandleAuth.jsx index e54eec6cc..3c49f0a02 100644 --- a/client/src/components/auth/HandleAuth.jsx +++ b/client/src/components/auth/HandleAuth.jsx @@ -19,6 +19,7 @@ const HandleAuth = (props) => { const api_token = params.get('token'); if (!api_token) return; + // create a refresh token isValidToken(api_token).then((isValid) => { setMagicLink(isValid); }); diff --git a/client/src/context/authContext.jsx b/client/src/context/authContext.jsx index 463e1d131..3dcb8b0bc 100644 --- a/client/src/context/authContext.jsx +++ b/client/src/context/authContext.jsx @@ -1,4 +1,4 @@ -import React, { createContext, useState, useEffect } from 'react'; +import { createContext, useState, useEffect } from 'react'; import { REACT_APP_CUSTOM_REQUEST_HEADER as headerToSend } from '../utils/globalSettings'; import * as authApi from '../api/auth'; import { useHistory } from 'react-router-dom'; @@ -46,12 +46,19 @@ const fetchAuth = async () => { }; try { + // check for refresh token + // if refresh token exists, obtain new jwt const response = await fetch('/api/auth/me', request); if (response.status !== 200) return { user: null, isAdmin: false, isError: true }; const user = await response.json(); - return { user, isAdmin: (user.accessLevel === 'admin' || user.accessLevel === 'superadmin'), isError: false }; + return { + user, + isAdmin: + user.accessLevel === 'admin' || user.accessLevel === 'superadmin', + isError: false, + }; } catch (error) { // this should never be hit... console.error('fetchAuth - error', error); diff --git a/client/src/utils/authUtils.js b/client/src/utils/authUtils.js index e940f3750..f97b30fd1 100644 --- a/client/src/utils/authUtils.js +++ b/client/src/utils/authUtils.js @@ -4,7 +4,8 @@ export function authLevelRedirect(user) { switch (userAccessLevel) { case 'superadmin': - loginRedirect = '/welcome' + loginRedirect = '/welcome'; + break; case 'admin': loginRedirect = '/welcome'; break; diff --git a/shared/authorizationUtils.js b/shared/authorizationUtils.js new file mode 100644 index 000000000..4b0a2eb9f --- /dev/null +++ b/shared/authorizationUtils.js @@ -0,0 +1,39 @@ +const { ROLES, ROLE_HIERARCHY } = require("../shared/roles"); + +const hasRole = (user, role) => { + if (!user || !user.accessLevel) return false; + return user.accessLevel === role; +}; + +const hasAnyRole = (user, ...roles) => { + if (!user || !user.accessLevel) return false; + return roles.includes(user.accessLevel); +}; + +const isAtLeast = (user, minimumRole) => { + if (!user || !user.accessLevel) return false; + return ROLE_HIERARCHY[user.accessLevel] >= ROLE_HIERARCHY[minimumRole]; +}; + +const isSuperAdmin = (user) => { + return hasRole(user, ROLES.SUPER_ADMIN); +}; + +const isAdmin = (user) => { + return hasAnyRole(user, ROLES.ADMIN, ROLES.SUPER_ADMIN); +}; + +const isProjectManager = (user) => { + return hasRole(user, ROLES.PROJECT_MANAGER); +}; + +const AuthUtils = { + isSuperAdmin, + isAdmin, + isProjectManager, + hasRole, + hasAnyRole, + isAtLeast, +}; + +module.exports = AuthUtils; diff --git a/shared/roles.js b/shared/roles.js new file mode 100644 index 000000000..93fe89bc0 --- /dev/null +++ b/shared/roles.js @@ -0,0 +1,17 @@ +// constants/roles.js + +const ROLES = Object.freeze({ + SUPER_ADMIN: "super_admin", + ADMIN: "admin", + PROJECT_MANAGER: "project_manager", + USER: "user", +}); + +const ROLE_HIERARCHY = Object.freeze({ + [ROLES.USER]: 1, + [ROLES.PROJECT_MANAGER]: 2, + [ROLES.ADMIN]: 3, + [ROLES.SUPER_ADMIN]: 4, +}); + +module.exports = { ROLES, ROLE_HIERARCHY }; From f411f17ba91181631ef05f86fa1bcd7a359d270c Mon Sep 17 00:00:00 2001 From: Richy Teas Date: Wed, 24 Dec 2025 03:50:33 -0800 Subject: [PATCH 2/4] fix backend/frontend sync for role/authUtils.js, adding minimumRole for route: /api/projects --- backend/controllers/user.controller.js | 18 ++++------ backend/middleware/auth.middleware.js | 49 +++++++++++++++++++------- backend/routers/projects.router.js | 12 ++++--- client/src/context/authContext.jsx | 11 ++++-- shared/authorizationUtils.js | 27 ++++++++++---- shared/roles.js | 11 +++++- 6 files changed, 89 insertions(+), 39 deletions(-) diff --git a/backend/controllers/user.controller.js b/backend/controllers/user.controller.js index c9dd0c402..476970bb3 100644 --- a/backend/controllers/user.controller.js +++ b/backend/controllers/user.controller.js @@ -5,7 +5,12 @@ const EmailController = require('./email.controller'); const { CONFIG_AUTH } = require('../config'); const { User, Project, RefreshToken } = require('../models'); -const { generateRefreshToken, getClientIp, hashToken } = require('../middleware/auth.middleware'); +const { + generateRefreshToken, + getClientIp, + hashToken, + generateAccessToken, +} = require('../middleware/auth.middleware'); const expectedHeader = process.env.CUSTOM_REQUEST_HEADER; @@ -194,17 +199,6 @@ UserController.delete = async function (req, res) { } }; -function generateAccessToken(user, auth_origin) { - // expires after half and hour (1800 seconds = 30 minutes) - return jwt.sign( - { id: user.id, role: user.accessLevel, auth_origin: auth_origin }, - CONFIG_AUTH.SECRET, - { - expiresIn: `${CONFIG_AUTH.ACCESS_TOKEN_EXPIRATION_SEC}s`, - }, - ); -} - UserController.createUser = function (req, res) { const { firstName, lastName, email } = req.body; const { origin } = req.headers; diff --git a/backend/middleware/auth.middleware.js b/backend/middleware/auth.middleware.js index 16082207d..daf1dfce0 100644 --- a/backend/middleware/auth.middleware.js +++ b/backend/middleware/auth.middleware.js @@ -3,19 +3,20 @@ const { CONFIG_AUTH } = require('../config'); const { RefreshToken, User } = require('../models'); const crypto = require('crypto'); - -const { hasAnyRole } = require('../../shared/authorizationUtils'); +const AuthUtils = require('../../shared/authorizationUtils'); const SECRET_KEY = process.env.JWT_SECRET; // Utility functions -function generateAccessToken(user) { +function generateAccessToken(user, auth_origin) { return jwt.sign( { - user_id: user._id, + id: user._id, email: user.email, + role: user.accessLevel, accessLevel: user.accessLevel, + auth_origin: auth_origin, }, SECRET_KEY, { expiresIn: '30m' }, @@ -47,15 +48,18 @@ function getClientIp(req) { async function authenticateAccessToken(req, res, next) { try { // Extract token from Authorization header - const authHeader = + let accessToken = req.cookies.token || req.headers['x-access-token'] || req.headers['authorization']; - const token = authHeader?.split(' ')[1]; - if (!token) { + if (!accessToken) { return res.status(401).json({ error: 'Access token required' }); } - const decoded = jwt.verify(token, SECRET_KEY); + if (accessToken.startsWith('Bearer ')) { + accessToken = accessToken.slice(7, accessToken.length); + } + + const decoded = jwt.verify(accessToken, SECRET_KEY); // Attach user info to request req.user = decoded; @@ -74,7 +78,7 @@ async function authenticateAccessToken(req, res, next) { } // shorthand for authenticateAccessToken -const authenticate = authenticateAccessToken; +const authUser = authenticateAccessToken; async function authenticateRefreshToken(req, res, next) { try { @@ -116,11 +120,11 @@ function requireRole(...roles) { return res.status(401).json({ error: 'Authentication required' }); } - if (!hasAnyRole(req.user, roles)) { + if (!AuthUtils.hasAnyRole(req.user, roles)) { return res.status(403).json({ error: 'Insufficient permissions', required_role: roles, - your_role: req.user.role, + your_role: req.user.accessLevel, }); } @@ -128,9 +132,27 @@ function requireRole(...roles) { }; } +function requireMinimumRole(role) { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const user = req.user; + if (!AuthUtils.hasMinimumRole(user, role)) { + return res.status(403).json({ + error: 'Insufficient permissions', + required_minimum_role: role, + your_role: req.user.accessLevel, + }); + } + next(); + }; +} + function verifyToken(req, res, next) { // Allow users to set token - + let token = req.headers['x-access-token'] || req.headers['authorization']; if (token.startsWith('Bearer ')) { // Remove Bearer from string @@ -164,9 +186,10 @@ function verifyCookie(req, res, next) { module.exports = { authenticateAccessToken, - authenticate, + authUser, authenticateRefreshToken, requireRole, + requireMinimumRole, generateAccessToken, generateRefreshToken, getClientIp, diff --git a/backend/routers/projects.router.js b/backend/routers/projects.router.js index c60dd113d..2fd5209c9 100644 --- a/backend/routers/projects.router.js +++ b/backend/routers/projects.router.js @@ -3,23 +3,25 @@ const router = express.Router(); const { ProjectController } = require('../controllers'); const { AuthUtil } = require('../middleware'); +const { ROLES } = require('../../shared/roles'); +router.use(AuthUtil.authUser, AuthUtil.requireMinimumRole(ROLES.PROJECT_MANAGER)); // The base is /api/projects router.get('/', ProjectController.project_list); // Its a put because we have to send the PM projects to be filtered here router.put('/', ProjectController.pm_filtered_projects); -router.post('/', AuthUtil.verifyCookie, ProjectController.create); +router.post('/', ProjectController.create); -router.get('/:ProjectId', AuthUtil.verifyCookie, ProjectController.project_by_id); +router.get('/:ProjectId', ProjectController.project_by_id); -router.put('/:ProjectId', AuthUtil.verifyCookie, ProjectController.update); +router.put('/:ProjectId', ProjectController.update); // Update project's managedByUsers in db -router.patch('/:ProjectId', AuthUtil.verifyCookie, ProjectController.updateManagedByUsers); +router.patch('/:ProjectId', ProjectController.updateManagedByUsers); // Bulk update for editing project members -router.post('/bulk-updates', AuthUtil.verifyCookie, ProjectController.bulkUpdateManagedByUsers); +router.post('/bulk-updates', ProjectController.bulkUpdateManagedByUsers); module.exports = router; diff --git a/client/src/context/authContext.jsx b/client/src/context/authContext.jsx index 3dcb8b0bc..11c99f224 100644 --- a/client/src/context/authContext.jsx +++ b/client/src/context/authContext.jsx @@ -2,6 +2,8 @@ import { createContext, useState, useEffect } from 'react'; import { REACT_APP_CUSTOM_REQUEST_HEADER as headerToSend } from '../utils/globalSettings'; import * as authApi from '../api/auth'; import { useHistory } from 'react-router-dom'; +import { isAdmin } from '../../../shared/authorizationUtils'; +import { ROLES } from '../../../shared/roles'; export const AuthContext = createContext(); @@ -48,6 +50,12 @@ const fetchAuth = async () => { try { // check for refresh token // if refresh token exists, obtain new jwt + console.log('ROLES:', ROLES); + console.log('isAdmin:', typeof isAdmin); + + const testUser = { accessLevel: ROLES.ADMIN }; + console.log('Is admin?', isAdmin(testUser)); // true + const response = await fetch('/api/auth/me', request); if (response.status !== 200) return { user: null, isAdmin: false, isError: true }; @@ -55,8 +63,7 @@ const fetchAuth = async () => { const user = await response.json(); return { user, - isAdmin: - user.accessLevel === 'admin' || user.accessLevel === 'superadmin', + isAdmin: isAdmin(user), isError: false, }; } catch (error) { diff --git a/shared/authorizationUtils.js b/shared/authorizationUtils.js index 4b0a2eb9f..1b727e8a0 100644 --- a/shared/authorizationUtils.js +++ b/shared/authorizationUtils.js @@ -1,16 +1,20 @@ -const { ROLES, ROLE_HIERARCHY } = require("../shared/roles"); +import { ROLES, ROLE_HIERARCHY } from "./roles.js"; +// Requires user to have the role exactly matching the provided role const hasRole = (user, role) => { if (!user || !user.accessLevel) return false; return user.accessLevel === role; }; +// Checks user for any of the listed roles const hasAnyRole = (user, ...roles) => { if (!user || !user.accessLevel) return false; return roles.includes(user.accessLevel); }; -const isAtLeast = (user, minimumRole) => { +// Checks if user has at least the minimum role based on hierarchy +// See shared/roles.js for hierarchy definition +const hasMinimumRole = (user, minimumRole) => { if (!user || !user.accessLevel) return false; return ROLE_HIERARCHY[user.accessLevel] >= ROLE_HIERARCHY[minimumRole]; }; @@ -27,13 +31,24 @@ const isProjectManager = (user) => { return hasRole(user, ROLES.PROJECT_MANAGER); }; -const AuthUtils = { +// CommonJS export +if (typeof module !== "undefined" && module.exports) { + module.exports = { + isSuperAdmin, + isAdmin, + isProjectManager, + hasRole, + hasAnyRole, + hasMinimumRole, + }; +} + +// ES Module export +export { isSuperAdmin, isAdmin, isProjectManager, hasRole, hasAnyRole, - isAtLeast, + hasMinimumRole, }; - -module.exports = AuthUtils; diff --git a/shared/roles.js b/shared/roles.js index 93fe89bc0..411d2155e 100644 --- a/shared/roles.js +++ b/shared/roles.js @@ -14,4 +14,13 @@ const ROLE_HIERARCHY = Object.freeze({ [ROLES.SUPER_ADMIN]: 4, }); -module.exports = { ROLES, ROLE_HIERARCHY }; +// CommonJS export (for backend) +if (typeof module !== "undefined" && module.exports) { + module.exports = { + ROLES, + ROLE_HIERARCHY, + }; +} + +// ES Module export (for frontend) +export { ROLES, ROLE_HIERARCHY }; From 8f5886d2055452da3b6184a232ac9ab129d5eba2 Mon Sep 17 00:00:00 2001 From: Richy Teas Date: Mon, 23 Feb 2026 02:56:56 -0800 Subject: [PATCH 3/4] Refactoring authutils code backend. Integrating client/fe to use jwt via authcontext. AuthZ code builds on shared/roles and shared/authorizationUtils for a single source of truth for roles/accessLevels --- backend/config/auth.config.js | 6 +- backend/controllers/user.controller.js | 19 +- backend/middleware/auth.middleware.js | 28 +- backend/middleware/index.js | 6 +- backend/middleware/token.middleware.js | 27 - backend/models/refreshToken.model.js | 4 +- backend/routers/auth.router.js | 7 +- backend/routers/grantpermission.router.js | 225 +- backend/routers/projects.router.js | 4 +- backend/routers/projects.router.test.js | 2 +- backend/routers/recurringEvents.router.js | 8 +- backend/routers/users.router.js | 15 +- client/src/components/Navbar.jsx | 12 +- client/src/components/ProjectForm.jsx | 16 +- client/src/components/auth/Auth.jsx | 20 +- client/src/components/auth/HandleAuth.jsx | 51 +- .../manageProjects/selectProject.jsx | 15 +- .../src/components/user-admin/EditUsers.jsx | 42 +- client/src/context/authContext.jsx | 149 +- client/src/pages/ManageProjects.jsx | 76 +- client/src/pages/ProjectList.jsx | 59 +- client/src/utils/authUtils.js | 20 - shared/authorizationUtils.js | 2 +- shared/roles.js | 3 + yarn.lock | 2199 +++++++---------- 25 files changed, 1336 insertions(+), 1679 deletions(-) delete mode 100644 backend/middleware/token.middleware.js delete mode 100644 client/src/utils/authUtils.js diff --git a/backend/config/auth.config.js b/backend/config/auth.config.js index 871ba4cad..596bf4289 100644 --- a/backend/config/auth.config.js +++ b/backend/config/auth.config.js @@ -1,10 +1,10 @@ - module.exports = { SECRET: 'c0d7d0716e4cecffe9dcc77ff90476d98f5aace08ea40f5516bd982b06401021191f0f24cd6759f7d8ca41b64f68d0b3ad19417453bddfd1dbe8fcb197245079', CUSTOM_REQUEST_HEADER: process.env.CUSTOM_REQUEST_HEADER, - ACCESS_TOKEN_EXPIRATION_SEC: 900, + // 15 minutes + ACCESS_TOKEN_EXPIRATION: '15m', + ACCESS_TOKEN_EXPIRATION_MS: 15 * 60 * 1000, // 30 days REFRESH_TOKEN_EXPIRATION_MS: 30 * 24 * 60 * 60 * 1000, }; - diff --git a/backend/controllers/user.controller.js b/backend/controllers/user.controller.js index 476970bb3..c9a843241 100644 --- a/backend/controllers/user.controller.js +++ b/backend/controllers/user.controller.js @@ -286,21 +286,30 @@ UserController.verifySignIn = async function (req, res) { }; UserController.verifyMe = async function (req, res) { - const user = await User.findById(req.userId); - return res.status(200).send(user); + return res.status(200).send(req.user); }; UserController.logout = async function (req, res) { - await RefreshToken.deleteOne({ id: req.refreshToken.doc._id }); - return res.clearCookie('token').status(200).send('Successfully logged out.'); + try { + await RefreshToken.deleteOne({ _id: req.refreshToken._id }); + return res.clearCookie('token').status(200).send('Successfully logged out.'); + } catch (err) { + console.error(err); + return res.status(500).send('Error occurred while logging out.'); + } }; UserController.refreshAccessToken = async function (req, res) { const accessToken = generateAccessToken(req.user, req.auth_origin); + const decoded = jwt.decode(accessToken); + return res .cookie('token', accessToken, { httpOnly: true }) .status(200) - .send('Access token refreshed.'); + .json({ + user: req.user, + expiresAt: decoded.exp * 1000, // Convert JWT exp (seconds) to milliseconds + }); }; // Update user's managedProjects diff --git a/backend/middleware/auth.middleware.js b/backend/middleware/auth.middleware.js index daf1dfce0..ba6ec3b5b 100644 --- a/backend/middleware/auth.middleware.js +++ b/backend/middleware/auth.middleware.js @@ -19,7 +19,7 @@ function generateAccessToken(user, auth_origin) { auth_origin: auth_origin, }, SECRET_KEY, - { expiresIn: '30m' }, + { expiresIn: CONFIG_AUTH.ACCESS_TOKEN_EXPIRATION }, ); } @@ -104,8 +104,9 @@ async function authenticateRefreshToken(req, res, next) { return res.status(401).json({ error: 'User not found for this token' }); } - // Attach user to request + // Attach user & refresh token to request for downstream handlers req.user = user; + req.refreshToken = tokenDoc; next(); } catch (error) { @@ -150,28 +151,6 @@ function requireMinimumRole(role) { }; } -function verifyToken(req, res, next) { - // Allow users to set token - - let token = req.headers['x-access-token'] || req.headers['authorization']; - if (token.startsWith('Bearer ')) { - // Remove Bearer from string - token = token.slice(7, token.length); - } - if (!token) { - return res.sendStatus(403); - } - - try { - const decoded = jwt.verify(token, CONFIG_AUTH.SECRET); - res.cookie('token', token, { httpOnly: true }); - req.userId = decoded.id; - return next(); - } catch (err) { - if (err) return res.sendStatus(401); - } -} - function verifyCookie(req, res, next) { jwt.verify(req.cookies.token, CONFIG_AUTH.SECRET, (err, decoded) => { if (err) { @@ -194,6 +173,5 @@ module.exports = { generateRefreshToken, getClientIp, hashToken, - verifyToken, verifyCookie, }; diff --git a/backend/middleware/index.js b/backend/middleware/index.js index 397b37c3f..16194a369 100644 --- a/backend/middleware/index.js +++ b/backend/middleware/index.js @@ -1,9 +1,7 @@ -const AuthUtil = require('./auth.middleware'); +const Auth = require('./auth.middleware'); const verifyUser = require('./user.middleware'); -const verifyToken = require('./token.middleware'); module.exports = { - AuthUtil, + Auth, verifyUser, - verifyToken, }; diff --git a/backend/middleware/token.middleware.js b/backend/middleware/token.middleware.js deleted file mode 100644 index 640873a5f..000000000 --- a/backend/middleware/token.middleware.js +++ /dev/null @@ -1,27 +0,0 @@ -const jwt = require('jsonwebtoken'); -const { CONFIG_AUTH } = require('../config'); - -async function isTokenValid(req, res, next) { - let token = req.headers['x-access-token'] || req.headers['authorization']; - if (token.startsWith('Bearer ')) { - // Remove Bearer from string - token = token.slice(7, token.length); - } - - if (!token) { - return res.sendStatus(400); - } - - try { - jwt.verify(token, CONFIG_AUTH.SECRET); - next(); - } catch (err) { - return res.sendStatus(403); - } -} - -const verifyToken = { - isTokenValid, -}; - -module.exports = verifyToken; diff --git a/backend/models/refreshToken.model.js b/backend/models/refreshToken.model.js index e4b5bb41d..ca66f0164 100644 --- a/backend/models/refreshToken.model.js +++ b/backend/models/refreshToken.model.js @@ -11,11 +11,11 @@ const refreshTokenSchema = mongoose.Schema({ index: true, }, hash: { type: String, required: true, unique: true, immutable: true }, - createdAt: { type: Date, required: true, default: Date.now(), immutable: true }, + createdAt: { type: Date, required: true, default: () => Date.now(), immutable: true }, expiresAt: { type: Date, required: true, - default: Date.now() + CONFIG_AUTH.REFRESH_TOKEN_EXPIRATION_MS, + default: () => Date.now() + CONFIG_AUTH.REFRESH_TOKEN_EXPIRATION_MS, }, deviceInfo: { ipAddress: String, diff --git a/backend/routers/auth.router.js b/backend/routers/auth.router.js index 4548db0f6..e6ac40d14 100644 --- a/backend/routers/auth.router.js +++ b/backend/routers/auth.router.js @@ -1,12 +1,11 @@ const express = require('express'); -const { AuthUtil, verifyUser, verifyToken } = require('../middleware'); +const { Auth, verifyUser } = require('../middleware'); const { UserController } = require('../controllers/'); const { authApiValidator } = require('../validators'); const { authenticateRefreshToken } = require('../middleware/auth.middleware'); const router = express.Router(); - router.use(function (req, res, next) { res.header('Access-Control-Allow-Headers', 'x-access-token, Origin, Content-Type, Accept'); next(); @@ -23,9 +22,9 @@ router.post('/refresh-access-token', [authenticateRefreshToken], UserController. router.post('/signin', [authApiValidator.validateSigninUserAPICall], UserController.signin); -router.post('/verify-signin', [verifyToken.isTokenValid], UserController.verifySignIn); +router.post('/verify-signin', [Auth.authUser], UserController.verifySignIn); -router.post('/me', [AuthUtil.verifyCookie], UserController.verifyMe); +router.post('/me', [Auth.authUser], UserController.verifyMe); router.post('/logout', [authenticateRefreshToken], UserController.logout); diff --git a/backend/routers/grantpermission.router.js b/backend/routers/grantpermission.router.js index 65fda8334..5f8858650 100644 --- a/backend/routers/grantpermission.router.js +++ b/backend/routers/grantpermission.router.js @@ -1,18 +1,21 @@ -const express = require("express"); +const express = require('express'); const router = express.Router(); -const fs = require("fs"); +const fs = require('fs'); -const { google } = require("googleapis"); +const { google } = require('googleapis'); const async = require('async'); -const fetch = require("node-fetch"); +const fetch = require('node-fetch'); +const { authUser } = require('../middleware/auth.middleware'); +const AuthUtils = require('../../shared/authorizationUtils'); +const { ROLES } = require('../../shared/roles'); -const SCOPES = ["https://www.googleapis.com/auth/drive"]; +const SCOPES = ['https://www.googleapis.com/auth/drive']; // placeholder org for testing -const githubOrganization = "testvrms"; +const githubOrganization = 'testvrms'; // GET /api/grantpermission/googleDrive -router.post("/googleDrive", async (req, res) => { +router.post('/googleDrive', async (req, res) => { let credentials = JSON.parse(process.env.GOOGLECREDENTIALS); //checks if email and file to change are in req.body @@ -22,12 +25,8 @@ router.post("/googleDrive", async (req, res) => { const { client_secret, client_id, redirect_uris } = credentials; - const oAuth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris[1] - ); - console.log("AFTERCLIENT"); + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[1]); + console.log('AFTERCLIENT'); // if (err) // return res.status(500).send({ // message: "Error loading client secret file:" + err.message, @@ -36,27 +35,24 @@ router.post("/googleDrive", async (req, res) => { const tokenObject = { access_token: process.env.GOOGLE_ACCESS_TOKEN, refresh_token: process.env.GOOGLE_REFRESH_TOKEN, - scope: "https://www.googleapis.com/auth/drive", - token_type: "Bearer", + scope: 'https://www.googleapis.com/auth/drive', + token_type: 'Bearer', expiry_date: process.env.GOOGLE_EXPIRY_DATE, }; oAuth2Client.setCredentials(tokenObject); - console.log("AFTR OAUTH"); + console.log('AFTR OAUTH'); // sends google drive grant permission from VRMS to email try { - const result = await grantPermission( - oAuth2Client, - req.body.email, - req.body.file - ); + const result = await grantPermission(oAuth2Client, req.body.email, req.body.file); if (result.success) { - const successObject = { message: "Success!" }; + const successObject = { message: 'Success!' }; return res.status(200).send(successObject); } else { return res.sendStatus(400); } } catch (err) { + console.error(err.message); return res.sendStatus(500); } }); @@ -64,20 +60,21 @@ router.post("/googleDrive", async (req, res) => { // GET /api/grantpermission/gitHub (checks if it can update the db data) // Route accounts for onboaring admins or regular users -router.post("/gitHub", async (req, res) => { - const { teamName, accessLevel, handle } = req.body; +router.post('/gitHub', authUser, async (req, res) => { + const { teamName, handle } = req.body; const userHandle = handle; const baseTeamSlug = createSlug(teamName); - const managerTeamSlug = baseTeamSlug + "-managers"; - const adminTeamSlug = baseTeamSlug + "-admins"; + const managerTeamSlug = baseTeamSlug + '-managers'; + const adminTeamSlug = baseTeamSlug + '-admins'; const teamSlugs = [baseTeamSlug, managerTeamSlug]; - if (accessLevel === "admin" || accessLevel === "superadmin") teamSlugs.push(adminTeamSlug); - + if (AuthUtils.hasMinimumRole(req.user, ROLES.ADMIN)) { + teamSlugs.push(adminTeamSlug); + } function createSlug(string) { let slug = string.toLowerCase(); - return slug.split(" ").join("-"); + return slug.split(' ').join('-'); } try { @@ -85,7 +82,7 @@ router.post("/gitHub", async (req, res) => { const userStatus = await checkOrgMembershipStatus(userHandle); const orgMembershipStatus = userStatus ? userStatus - : (await inviteToOrg(userHandle)) && "pending"; + : (await inviteToOrg(userHandle)) && 'pending'; // Add user to github project teams console.log({ teamSlugs }); @@ -93,24 +90,24 @@ router.post("/gitHub", async (req, res) => { teamSlugs.map(async (slug) => { const result = await addToTeam(userHandle, slug); console.log({ slug }); - if (result === "team not found") { - throw new Error("team not found"); + if (result === 'team not found') { + throw new Error('team not found'); } if (!result) { - throw new Error("user not added to one or more teams"); + throw new Error('user not added to one or more teams'); } return; - }) + }), ); const result = { orgMembershipStatus, - teamMembershipStatus: "pending", + teamMembershipStatus: 'pending', }; - if (orgMembershipStatus === "active") { + if (orgMembershipStatus === 'active') { // user automatically added to team if active membership in org - result.teamMembershipStatus = "active"; + result.teamMembershipStatus = 'active'; // check if membership is public result.publicMembership = await checkPublicMembership(userHandle); @@ -121,19 +118,16 @@ router.post("/gitHub", async (req, res) => { return res.status(200).send(result); } catch (err) { - return res.status(400); + console.error(err.message); + return res.status(400).send({ message: 'Error occurred while processing request' }); } }); -router.post("/", async (req, res) => { - fs.readFile("credentials.json", async (err, content) => { +router.post('/', async (req, res) => { + fs.readFile('credentials.json', async (err, content) => { const credentialsObject = JSON.parse(content); const { client_secret, client_id, redirect_uris } = credentialsObject.web; - const oAuth2Client = new google.auth.OAuth2( - client_id, - client_secret, - redirect_uris[1] - ); + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[1]); // sends back error if credentials files cannot be read if (err) { @@ -155,6 +149,7 @@ router.post("/", async (req, res) => { setToken = true; } } catch (err) { + console.error(err.message); return res.sendStatus(400); } // if token is already placed into body request @@ -165,15 +160,11 @@ router.post("/", async (req, res) => { oAuth2Client.setCredentials(token); try { // another callback function that returns promises can replace this method - console.log("TRY"); - const result = await grantPermission( - oAuth2Client, - req.body.email, - req.body.file - ); + console.log('TRY'); + const result = await grantPermission(oAuth2Client, req.body.email, req.body.file); if (result.success) { { - const successObject = { message: "Success!" }; + const successObject = { message: 'Success!' }; if (setToken) { successObject.token = token; } @@ -201,7 +192,7 @@ router.post("/", async (req, res) => { */ function sendURL(oAuth2Client) { const authUrl = oAuth2Client.generateAuthUrl({ - access_type: "offline", + access_type: 'offline', scope: SCOPES, }); return authUrl; @@ -219,7 +210,7 @@ function sendToken(oAuth2Client, code) { if (err) reject({ success: false, - message: "Error retrieving access token" + err.message, + message: 'Error retrieving access token' + err.message, }); resolve({ success: true, token }); }); @@ -232,37 +223,37 @@ function sendToken(oAuth2Client, code) { * @returns {Promise} Promise with an object that contains the boolean success to determine * what to do in the route. Rejection objects also have a message field. */ -function listFiles(auth) { - const drive = google.drive({ version: "v3", auth }); - return new Promise(function (resolve, reject) { - drive.files.list( - { - pageSize: 10, - fields: "nextPageToken, files(id, name)", - }, - (err, res) => { - if (err) - reject({ - success: false, - message: "The API returned an error: " + err.message, - }); - const files = res.data.files; - if (files.length) { - console.log("Files:"); - files.map((file) => { - console.log(`${file.name} (${file.id})`); - }); - resolve({ success: true }); - } else { - return reject({ - success: false, - message: "No files found", - }); - } - } - ); - }); -} +// function listFiles(auth) { +// const drive = google.drive({ version: 'v3', auth }); +// return new Promise(function (resolve, reject) { +// drive.files.list( +// { +// pageSize: 10, +// fields: 'nextPageToken, files(id, name)', +// }, +// (err, res) => { +// if (err) +// reject({ +// success: false, +// message: 'The API returned an error: ' + err.message, +// }); +// const files = res.data.files; +// if (files.length) { +// console.log('Files:'); +// files.map((file) => { +// console.log(`${file.name} (${file.id})`); +// }); +// resolve({ success: true }); +// } else { +// return reject({ +// success: false, +// message: 'No files found', +// }); +// } +// }, +// ); +// }); +// } /** * Gives Google Drive permission to an email address for the file ID @@ -273,39 +264,38 @@ function listFiles(auth) { * what to do in the route. Rejection objects also have a message field. */ function grantPermission(auth, email, fileId) { - console.log("GRANT PERMISSIONS"); + console.log('GRANT PERMISSIONS'); var permissions = [ { - type: "user", - role: "writer", + type: 'user', + role: 'writer', emailAddress: email, }, ]; return new Promise(function (resolve, reject) { async.eachSeries(permissions, function (permission, permissionCallback) { - const drive = google.drive({ version: "v3", auth }); + const drive = google.drive({ version: 'v3', auth }); drive.permissions.create( { resource: permission, fileId: fileId, - fields: "id", - emailMessage: - "Hi there! You are receiving this message from the VRMS team. Enjoy!", + fields: 'id', + emailMessage: 'Hi there! You are receiving this message from the VRMS team. Enjoy!', }, (err, res) => { if (err) { - console.log("PROMISE ERROR", err); + console.log('PROMISE ERROR', err); reject({ success: false, - message: "The API returned an error: " + err.message, + message: 'The API returned an error: ' + err.message, }); } else { - console.log("RES", res); + console.log('RES', res); permissionCallback(); resolve({ success: true }); } - } + }, ); }); }); @@ -317,24 +307,21 @@ function grantPermission(auth, email, fileId) { * @param {str} githubHandle */ function checkOrgMembershipStatus(githubHandle) { - return fetch( - `https://api.github.com/orgs/${githubOrganization}/memberships/${githubHandle}`, - { - method: "GET", - headers: { - Authorization: `token ${process.env.GITHUB_TOKEN}`, - }, - } - ) + return fetch(`https://api.github.com/orgs/${githubOrganization}/memberships/${githubHandle}`, { + method: 'GET', + headers: { + Authorization: `token ${process.env.GITHUB_TOKEN}`, + }, + }) .then((res) => { if (res.status === 200) return res.json(); if (res.status === 404) return false; - return new Error("Unexpected result"); + return new Error('Unexpected result'); }) .then((res) => { if (res) { - return res.state === "pending" ? "pending" : "active"; + return res.state === 'pending' ? 'pending' : 'active'; } else { return false; } @@ -349,14 +336,12 @@ function inviteToOrg(githubHandle) { return fetch( `https://api.github.com/orgs/${githubOrganization}/memberships/${githubHandle}?role=member`, { - method: "PUT", + method: 'PUT', headers: { Authorization: `token ${process.env.GITHUB_TOKEN}`, }, - } - ).then((res) => - res.status === 200 ? true : new Error("Unexpected response") - ); + }, + ).then((res) => (res.status === 200 ? true : new Error('Unexpected response'))); } /** @@ -371,19 +356,19 @@ function addToTeam(githubHandle, teamSlug) { return fetch( `https://api.github.com/orgs/${githubOrganization}/teams/${teamSlug}/memberships/${githubHandle}`, { - method: "PUT", + method: 'PUT', headers: { Authorization: `token ${process.env.GITHUB_TOKEN}`, }, - } + }, ) .then((res) => ({ result: res.json(), status: res.status, })) .then((res) => { - if (res.result.message === "Not Found") { - return "team not found"; // how can I just throw an error here instead? + if (res.result.message === 'Not Found') { + return 'team not found'; // how can I just throw an error here instead? } else { console.log(res.status); return Boolean(res.status === 200); @@ -393,12 +378,10 @@ function addToTeam(githubHandle, teamSlug) { function check2FA(githubHandle) { return fetch( - `https://api.github.com/orgs/${githubOrganization}/members?filter=2fa_disabled` + `https://api.github.com/orgs/${githubOrganization}/members?filter=2fa_disabled`, ).then((no2FAMembersArr) => { if (no2FAMembersArr.length) { - return !no2FAMembersArr.includes( - (member) => member.login === githubHandle - ); + return !no2FAMembersArr.includes((member) => member.login === githubHandle); } return true; @@ -407,7 +390,7 @@ function check2FA(githubHandle) { function checkPublicMembership(githubHandle) { return fetch( - `https://api.github.com/orgs/${githubOrganization}/public_members/${githubHandle}` + `https://api.github.com/orgs/${githubOrganization}/public_members/${githubHandle}`, ).then((res) => (res.status === 204 ? true : false)); } diff --git a/backend/routers/projects.router.js b/backend/routers/projects.router.js index 2fd5209c9..d98ee8e5d 100644 --- a/backend/routers/projects.router.js +++ b/backend/routers/projects.router.js @@ -2,10 +2,10 @@ const express = require('express'); const router = express.Router(); const { ProjectController } = require('../controllers'); -const { AuthUtil } = require('../middleware'); +const { Auth } = require('../middleware'); const { ROLES } = require('../../shared/roles'); -router.use(AuthUtil.authUser, AuthUtil.requireMinimumRole(ROLES.PROJECT_MANAGER)); +router.use(Auth.authUser, Auth.requireMinimumRole(ROLES.PROJECT_MANAGER)); // The base is /api/projects router.get('/', ProjectController.project_list); diff --git a/backend/routers/projects.router.test.js b/backend/routers/projects.router.test.js index 100e59635..26ddcf624 100644 --- a/backend/routers/projects.router.test.js +++ b/backend/routers/projects.router.test.js @@ -1,7 +1,7 @@ // Mock for Project controller jest.mock('../controllers/project.controller'); -// Mock AuthUtil.verifyCookie middleware +// Mock Auth.verifyCookie middleware const mockVerifyCookie = jest.fn((req, res, next) => next()); jest.mock('../middleware/auth.middleware', () => ({ verifyCookie: mockVerifyCookie, diff --git a/backend/routers/recurringEvents.router.js b/backend/routers/recurringEvents.router.js index 0602f2dee..dbb4bce83 100644 --- a/backend/routers/recurringEvents.router.js +++ b/backend/routers/recurringEvents.router.js @@ -4,7 +4,7 @@ const cors = require('cors'); const { RecurringEvent } = require('../models/recurringEvent.model'); const { RecurringEventController } = require('../controllers/'); -const { AuthUtil } = require('../middleware'); +const { Auth } = require('../middleware'); // GET /api/recurringevents/ router.get('/', cors(), (req, res) => { @@ -47,10 +47,10 @@ router.get('/:id', (req, res) => { }); }); -router.post('/', AuthUtil.verifyCookie, RecurringEventController.create); +router.post('/', Auth.verifyCookie, RecurringEventController.create); -router.patch('/:RecurringEventId', AuthUtil.verifyCookie, RecurringEventController.update); +router.patch('/:RecurringEventId', Auth.verifyCookie, RecurringEventController.update); -router.delete('/:RecurringEventId', AuthUtil.verifyCookie, RecurringEventController.destroy); +router.delete('/:RecurringEventId', Auth.verifyCookie, RecurringEventController.destroy); module.exports = router; diff --git a/backend/routers/users.router.js b/backend/routers/users.router.js index bf6306442..60b5af4d5 100644 --- a/backend/routers/users.router.js +++ b/backend/routers/users.router.js @@ -1,7 +1,8 @@ const express = require('express'); const router = express.Router(); - +const { Auth } = require('../middleware'); const { UserController } = require('../controllers'); +const { ROLES } = require('../../shared/roles'); // The base is /api/users router.get('/', UserController.user_list); @@ -18,10 +19,18 @@ router.post('/', UserController.create); router.post('/bulk-updates', UserController.bulkUpdateManagedProjects); -router.patch('/:UserId', UserController.update); +router.patch( + '/:UserId', + [Auth.authUser, Auth.requireMinimumRole(ROLES.ADMIN)], + UserController.update, +); router.patch('/:UserId/managedProjects', UserController.updateManagedProjects); -router.delete('/:UserId', UserController.delete); +router.delete( + '/:UserId', + [Auth.authUser, Auth.requireMinimumRole(ROLES.ADMIN)], + UserController.delete, +); module.exports = router; diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index 19b124c13..67ffe28ce 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -1,14 +1,14 @@ -import React from 'react'; import { NavLink, withRouter } from 'react-router-dom'; import useAuth from '../hooks/useAuth'; import HflaImg from '../svg/hflalogo.svg'; import { Box, Button, Grid } from '@mui/material'; import { styled } from '@mui/system'; import theme from '../theme'; +import { ROLES } from '../../../shared/roles'; -const Navbar = (props) => { +const Navbar = () => { // check user accessLevel and adjust link accordingly - const { auth } = useAuth(); + const { auth, hasRole, hasMinimumRole, loggedIn } = useAuth(); const StyledButton = styled(Button)({ color: '#757575', @@ -30,7 +30,7 @@ const Navbar = (props) => { - + { )} {/* Admin auth -> Displays 2 links -> 'Users' and 'Projects'. */} - {(auth?.user?.accessLevel === 'admin' || auth?.user?.accessLevel === 'superadmin') && ( + {hasMinimumRole(ROLES.ADMIN) && ( <> USERS @@ -73,7 +73,7 @@ const Navbar = (props) => { )} {/* User auth -> Displays 1 link -> 'Projects' only. */} - {auth?.user?.accessLevel === 'user' && ( + {hasRole(ROLES.USER) && ( <> PROJECTS diff --git a/client/src/components/ProjectForm.jsx b/client/src/components/ProjectForm.jsx index b21e8b4ed..608047c83 100644 --- a/client/src/components/ProjectForm.jsx +++ b/client/src/components/ProjectForm.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useForm, useFormState } from 'react-hook-form'; import { @@ -21,6 +21,7 @@ import PlusIcon from '../svg/PlusIcon.svg?react'; import ValidatedTextField from './parts/form/ValidatedTextField'; import TitledBox from './parts/boxes/TitledBox'; import ChangesModal from './ChangesModal'; +import { ROLES } from '../../../shared/roles'; /** STYLES * -most TextField and InputLabel styles are controlled by the theme @@ -28,18 +29,18 @@ import ChangesModal from './ChangesModal'; * -the rest are inline */ -export const StyledButton = styled(Button)(({ theme }) => ({ +export const StyledButton = styled(Button)(() => ({ width: '150px', })); -const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({ +const StyledFormControlLabel = styled(FormControlLabel)(() => ({ width: 'max-content', '& .MuiFormControlLabel-label': { fontSize: '14px', }, })); -const StyledRadio = styled(Radio)(({ theme }) => ({ +const StyledRadio = styled(Radio)(() => ({ padding: '0px 0px 0px 0px', marginRight: '.5rem', })); @@ -69,7 +70,7 @@ export default function ProjectForm({ const history = useHistory(); // ----------------- States ----------------- - const { auth } = useAuth(); + const { hasMinimumRole } = useAuth(); const [isLoading, setIsLoading] = useState(false); const [locationType, setLocationType] = useState('remote'); // State to track the toggling from Project view to Edit Project View via edit icon. @@ -149,7 +150,7 @@ export default function ProjectForm({ }; // Toggles the project view to edit mode change. - const handleEditMode = (event) => { + const handleEditMode = () => { setEditMode(!editMode); // React hook form method to reset data back to original values. Triggered when Edit Mode is cancelled. reset({ @@ -235,8 +236,7 @@ export default function ProjectForm({ {projectName} - {auth.user.accessLevel === 'admin' || - auth.user.accessLevel == 'superadmin' ? ( + {hasMinimumRole(ROLES.ADMIN) ? ( { const pattern = /\b[a-z0-9._]+@[a-z0-9.-]+\.[a-z]{2,4}\b/i; const history = useHistory(); - const { auth } = useAuth(); + const { auth, getLoginRedirect } = useAuth(); const [email, setEmail] = useState(''); const [isDisabled, setIsDisabled] = useState(true); @@ -55,7 +53,7 @@ const Auth = () => { userData.user.managedProjects.length === 0 ) { showError( - "You don't have the correct access level to view the dashboard" + "You don't have the correct access level to view the dashboard", ); return; } @@ -65,12 +63,12 @@ const Auth = () => { history.push('/emailsent'); } else { showError( - 'We don’t recognize your email address. Please, create an account.' + 'We don’t recognize your email address. Please, create an account.', ); } } else { showError( - 'We don’t recognize your email address. Please, create an account.' + 'We don’t recognize your email address. Please, create an account.', ); } } @@ -90,13 +88,9 @@ const Auth = () => { } // This allows users who are not admin, but are allowed to manage projects, to login - let loginRedirect = ''; - if (auth?.user) { - loginRedirect = authLevelRedirect(auth.user); - } return auth?.user ? ( - + ) : (
diff --git a/client/src/components/auth/HandleAuth.jsx b/client/src/components/auth/HandleAuth.jsx index 3c49f0a02..1b7b5bda9 100644 --- a/client/src/components/auth/HandleAuth.jsx +++ b/client/src/components/auth/HandleAuth.jsx @@ -1,14 +1,13 @@ import { useState, useEffect } from 'react'; import { Redirect } from 'react-router-dom'; import { isValidToken } from '../../services/user.service'; -import { authLevelRedirect } from '../../utils/authUtils'; import { Box, CircularProgress, Typography } from '@mui/material'; import '../../sass/MagicLink.scss'; import useAuth from '../../hooks/useAuth'; const HandleAuth = (props) => { - const { auth, refreshAuth } = useAuth(); + const { auth, refreshAuth, getLoginRedirect } = useAuth(); const [isMagicLinkValid, setMagicLink] = useState(null); const [isLoaded, setIsLoaded] = useState(false); @@ -19,44 +18,28 @@ const HandleAuth = (props) => { const api_token = params.get('token'); if (!api_token) return; - // create a refresh token + // Validates token and creates HttpOnly cookies (access + refresh tokens) isValidToken(api_token).then((isValid) => { setMagicLink(isValid); }); }, [props.location.search]); - // Step 2: Refresh user auth (requires valid Magic Link) + // Step 2: After magic link is validated, refresh auth to get user data useEffect(() => { - if (!isMagicLinkValid) return; - if (!auth?.isError) return; - - refreshAuth(); - }, [isMagicLinkValid, refreshAuth, auth]); - - // Step 3: Set IsLoaded value to render Component - useEffect(() => { - if (!isMagicLinkValid) return; - - if (!auth || auth.isError) return; - - setIsLoaded(true); - }, [isMagicLinkValid, setIsLoaded, auth]); + if (isMagicLinkValid) { + refreshAuth(); + } else { + // Invalid magic link - show error immediately + setIsLoaded(true); + } + }, [isMagicLinkValid]); + // Step 3: Once we have user data, we're ready to redirect useEffect(() => { - if (!isLoaded) { - const timer = setTimeout(() => { - setIsLoaded(true); - }, 1000); - - return () => clearTimeout(timer); + if (auth?.user) { + setIsLoaded(true); } - }, [isLoaded]); - - let loginRedirect = ''; - - if (auth?.user) { - loginRedirect = authLevelRedirect(auth.user); - } + }, [auth]); return ( @@ -67,7 +50,11 @@ const HandleAuth = (props) => { Sorry, the link is not valid anymore. )} - {auth?.user && /* Redirect to /welcome */} + { + auth?.user && ( + + ) /* Redirect to /welcome */ + } ); }; diff --git a/client/src/components/manageProjects/selectProject.jsx b/client/src/components/manageProjects/selectProject.jsx index 13ed926f6..4f462d81c 100644 --- a/client/src/components/manageProjects/selectProject.jsx +++ b/client/src/components/manageProjects/selectProject.jsx @@ -1,25 +1,28 @@ -import React from 'react'; import { Link } from 'react-router-dom'; import '../../sass/ManageProjects.scss'; +import useAuth from '../../hooks/useAuth'; import { Button, Typography } from '@mui/material'; +import { ROLES } from '../../../../shared/roles'; -const SelectProject = ({ projects, accessLevel, user }) => { +const SelectProject = ({ projects }) => { + const { auth, isAdmin, isSuperAdmin, hasMinimumRole } = useAuth(); + const user = auth?.user; // If access level is 'admin', display all active projects. // If access level is 'user' display user managed projects. const managedProjects = projects ?.filter((proj) => { - if (accessLevel === 'admin' || accessLevel === 'superadmin') { + if (isAdmin() || isSuperAdmin()) { return proj.projectStatus === 'Active'; } // accessLevel is user - // eslint-disable-next-line no-underscore-dangle + return user?.managedProjects.includes(proj._id); }) .sort((a, b) => a.name?.localeCompare(b.name)) .map((p) => ( - // eslint-disable-next-line no-underscore-dangle +
  • {p.name ? p.name : '[unnamed project]'} @@ -31,7 +34,7 @@ const SelectProject = ({ projects, accessLevel, user }) => {
    Project Management
    - {accessLevel === 'admin' || accessLevel === 'superadmin' && ( + {hasMinimumRole(ROLES.ADMIN) && ( {' '} @@ -165,7 +169,7 @@ const EditUsers = ({ value={projectValue} label="Select a project" onChange={(event) => setProjectValue(event.target.value)} - disabled={isSuperAdmin} + disabled={superAdmin} > Select a project.. {activeProjects.map((result) => ( @@ -178,7 +182,7 @@ const EditUsers = ({ variant="contained" onClick={onSubmit} style={{ marginTop: '1rem' }} - disabled={isSuperAdmin} + disabled={superAdmin} > Add project diff --git a/client/src/context/authContext.jsx b/client/src/context/authContext.jsx index 11c99f224..cc5e8622b 100644 --- a/client/src/context/authContext.jsx +++ b/client/src/context/authContext.jsx @@ -2,8 +2,14 @@ import { createContext, useState, useEffect } from 'react'; import { REACT_APP_CUSTOM_REQUEST_HEADER as headerToSend } from '../utils/globalSettings'; import * as authApi from '../api/auth'; import { useHistory } from 'react-router-dom'; -import { isAdmin } from '../../../shared/authorizationUtils'; -import { ROLES } from '../../../shared/roles'; +import { + hasRole as checkHasRole, + hasAnyRole as checkHasAnyRole, + hasMinimumRole as checkHasMinimumRole, + isSuperAdmin as checkIsSuperAdmin, + isAdmin as checkIsAdmin, + isProjectManager as checkIsProjectManager, +} from '../../../shared/authorizationUtils'; export const AuthContext = createContext(); @@ -11,13 +17,61 @@ export const AuthProvider = ({ children }) => { const [auth, setAuth] = useState(); const history = useHistory(); + // On mount, check if user has valid session (HttpOnly cookies) useEffect(() => { refreshAuth(); }, []); + // Auto-refresh access token before it expires + useEffect(() => { + if (!auth?.user || !auth?.expiresAt) return; + + const expirationTime = auth.expiresAt; + const timeUntilExpiry = expirationTime - Date.now(); + + // If token already expired or expires in less than 1 minute, refresh immediately + if (timeUntilExpiry <= 60000) { + refreshAuth(); + return; + } + + // Set timeout to refresh 1 minute before expiry + const timeout = setTimeout(() => { + console.log('Auto-refreshing access token...'); + refreshAuth(); + }, timeUntilExpiry - 60000); + + return () => clearTimeout(timeout); + }, [auth?.expiresAt, auth?.user]); + const refreshAuth = async () => { - const userAuth = await fetchAuth(); - setAuth(userAuth); + const request = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-customrequired-header': headerToSend, + }, + credentials: 'include', // Send HttpOnly cookies (access + refresh tokens) + }; + + try { + const response = await fetch('/api/auth/refresh-access-token', request); + + if (response.status !== 200) { + // No valid session - clear auth state + setAuth(null); + return; + } + + const data = await response.json(); + + // Store user data and expiry time (tokens are in HttpOnly cookies) + // Backend should return: { user, expiresAt: timestamp } + setAuth(data); + } catch (error) { + console.error('refreshAuth error:', error); + setAuth(null); + } }; const logout = async () => { @@ -31,43 +85,64 @@ export const AuthProvider = ({ children }) => { setAuth(null); }; - return ( - - {children} - - ); -}; + // Returns true if user is logged in (auth state has a user object) + const loggedIn = () => !!auth?.user; + + // Wrapper methods that use current auth state + const hasRole = (role) => { + return checkHasRole(auth?.user, role); + }; -const fetchAuth = async () => { - const request = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-customrequired-header': headerToSend, - }, + const hasAnyRole = (...roles) => { + return checkHasAnyRole(auth?.user, ...roles); }; - try { - // check for refresh token - // if refresh token exists, obtain new jwt - console.log('ROLES:', ROLES); - console.log('isAdmin:', typeof isAdmin); + const hasMinimumRole = (minimumRole) => { + return checkHasMinimumRole(auth?.user, minimumRole); + }; - const testUser = { accessLevel: ROLES.ADMIN }; - console.log('Is admin?', isAdmin(testUser)); // true + const isSuperAdmin = () => { + return checkIsSuperAdmin(auth?.user); + }; - const response = await fetch('/api/auth/me', request); - if (response.status !== 200) - return { user: null, isAdmin: false, isError: true }; + // Checks if user is Admin, SuperAdmin + const isAdmin = () => { + return checkIsAdmin(auth?.user); + }; - const user = await response.json(); - return { - user, - isAdmin: isAdmin(user), - isError: false, - }; - } catch (error) { - // this should never be hit... - console.error('fetchAuth - error', error); - } + // Checks if user is Project Manager + const isProjectManager = () => { + return checkIsProjectManager(auth?.user); + }; + + const getLoginRedirect = () => { + if (!auth?.user) return '/'; + + // For now, all authenticated users go to /welcome + // Future: Could redirect based on role + // if (isSuperAdmin()) return '/admin'; + // if (isAdmin()) return '/admin'; + // if (isProjectManager()) return '/projects'; + return '/welcome'; + }; + + return ( + + {children} + + ); }; diff --git a/client/src/pages/ManageProjects.jsx b/client/src/pages/ManageProjects.jsx index 75f4512bd..8d8a3d29d 100644 --- a/client/src/pages/ManageProjects.jsx +++ b/client/src/pages/ManageProjects.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import SelectProject from '../components/manageProjects/selectProject'; import EditProject from '../components/manageProjects/editProject'; @@ -17,27 +17,26 @@ const PAGES = Object.freeze({ // Added styles for MUI components const loadingStyle = { - "display": "none", - "position": "absolute", - "display": "flex", - "flexDirection": "row", - "alignItems": "center", - "justifyContent": "center", - "zIndex": 1, - "top": 0, - "left": 0, - "right": 0, - "backgroundColor": "white", - "height": '100%', - "opacity": 0.6, -} + position: 'absolute', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1, + top: 0, + left: 0, + right: 0, + backgroundColor: 'white', + height: '100%', + opacity: 0.6, +}; const noStyle = { - "opacity": 0, - "display": "none", -} + opacity: 0, + display: 'none', +}; -const ManageProjects = ({ auth }) => { +const ManageProjects = () => { const { projectId } = useParams(); const [projects, setProjects] = useState(); const [projectToEdit, setProjectToEdit] = useState(); @@ -50,8 +49,6 @@ const ManageProjects = ({ auth }) => { const [projectsLoading, setProjectsLoading] = useState(false); const [eventsLoading, setEventsLoading] = useState(false); - const user = auth?.user; - const fetchProjects = useCallback(async () => { setProjectsLoading(true); const projectRes = await projectApiService.fetchProjects(); @@ -76,14 +73,14 @@ const ManageProjects = ({ auth }) => { const updateProject = useCallback( async (fieldName, fieldValue) => { await projectApiService.updateProject( - // eslint-disable-next-line no-underscore-dangle + projectToEdit._id, fieldName, - fieldValue + fieldValue, ); fetchProjects(); }, - [projectApiService, fetchProjects, projectToEdit] + [projectApiService, fetchProjects, projectToEdit], ); const createNewRecurringEvent = useCallback( @@ -91,7 +88,7 @@ const ManageProjects = ({ auth }) => { await recurringEventsApiService.createNewRecurringEvent(eventToCreate); fetchRecurringEvents(); }, - [recurringEventsApiService, fetchRecurringEvents] + [recurringEventsApiService, fetchRecurringEvents], ); const deleteRecurringEvent = useCallback( @@ -99,18 +96,18 @@ const ManageProjects = ({ auth }) => { await recurringEventsApiService.deleteRecurringEvent(recurringEventID); fetchRecurringEvents(); }, - [recurringEventsApiService, fetchRecurringEvents] + [recurringEventsApiService, fetchRecurringEvents], ); const updateRecurringEvent = useCallback( async (eventToUpdate, recurringEventID) => { await recurringEventsApiService.updateRecurringEvent( eventToUpdate, - recurringEventID + recurringEventID, ); fetchRecurringEvents(); }, - [recurringEventsApiService, fetchRecurringEvents] + [recurringEventsApiService, fetchRecurringEvents], ); const updateRegularEvent = useCallback( @@ -118,7 +115,7 @@ const ManageProjects = ({ auth }) => { await EventsApiService.updateEvent(eventToUpdate, eventId); fetchRegularEvents(); }, - [fetchRegularEvents, EventsApiService] + [fetchRegularEvents, EventsApiService], ); useEffect(() => { @@ -126,9 +123,9 @@ const ManageProjects = ({ auth }) => { if (projectId && projects) { setProjectToEdit( projects.find( - // eslint-disable-next-line no-underscore-dangle - (proj) => proj._id === projectId - ) + + (proj) => proj._id === projectId, + ), ); setComponentToDisplay(PAGES.editProjectInfo); } @@ -149,7 +146,6 @@ const ManageProjects = ({ auth }) => { projectToEdit={projectToEdit} recurringEvents={recurringEvents} updateProject={updateProject} - userAccessLevel={user.accessLevel} createNewRecurringEvent={createNewRecurringEvent} deleteRecurringEvent={deleteRecurringEvent} updateRecurringEvent={updateRecurringEvent} @@ -160,23 +156,17 @@ const ManageProjects = ({ auth }) => { break; // We are not using the SelectProject component anymore. Will remove soon. default: - displayedComponent = ( - - ); + displayedComponent = ; break; } return ( <> - + {displayedComponent} diff --git a/client/src/pages/ProjectList.jsx b/client/src/pages/ProjectList.jsx index 4bbb2eaa1..6c4c3afd9 100644 --- a/client/src/pages/ProjectList.jsx +++ b/client/src/pages/ProjectList.jsx @@ -1,16 +1,14 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import ProjectApiService from '../api/ProjectApiService'; import { styled } from '@mui/system'; -import { - Box, - CircularProgress, - Typography, - Button, -} from '@mui/material'; +import { Box, CircularProgress, Typography, Button } from '@mui/material'; import { Link } from 'react-router-dom'; import TitledBox from '../components/parts/boxes/TitledBox'; +import useAuth from '../hooks/useAuth'; +import { ROLES } from '../../../shared/roles'; + const StyledTypography = styled(Typography)({ textTransform: 'uppercase', color: '#1132F4', @@ -28,10 +26,11 @@ const StyledTypography = styled(Typography)({ * - will not see button to add a new project */ -export default function ProjectList({ auth }) { +export default function ProjectList() { + const { auth, hasMinimumRole, hasAnyRole } = useAuth(); const [projects, setProjects] = useState(null); const [projectApiService] = useState(new ProjectApiService()); - + const user = auth?.user; // On component mount, request projects data from API @@ -40,30 +39,29 @@ export default function ProjectList({ auth }) { async function fetchAllProjects() { let projectData; - if ( - user?.accessLevel === 'admin' || - user?.accessLevel === 'superadmin' - ) { + if (hasMinimumRole(ROLES.ADMIN)) { projectData = await projectApiService.fetchProjects(); } else if (user?.managedProjects?.length > 0) { // if user is not admin, but is a project manager, only show projects they manage - projectData = await projectApiService.fetchPMProjects(user.managedProjects); + projectData = await projectApiService.fetchPMProjects( + user.managedProjects, + ); } - + //sort the projects alphabetically - projectData = projectData.sort((a, b) => - a.name?.localeCompare(b.name) - ); + projectData = projectData.sort((a, b) => a.name?.localeCompare(b.name)); setProjects(projectData); } - + fetchAllProjects(); }, - [projectApiService, user.accessLevel, user.managedProjects] + [projectApiService, user.accessLevel, user.managedProjects], ); - const projsWithUsers = projects?.filter((project) => project.managedByUsers?.length > 0); + const projsWithUsers = projects?.filter( + (project) => project.managedByUsers?.length > 0, + ); console.log('Projects with users:', projsWithUsers); // Render loading circle until project data is served from API @@ -82,7 +80,7 @@ export default function ProjectList({ auth }) { - {(user?.accessLevel === 'admin' || user?.accessLevel === 'superadmin') && ( + {hasAnyRole(ROLES.ADMIN, ROLES.SUPER_ADMIN) && (