From cb32a0df92bf9645dc53fc2d5ae0b9a716f0d7dc Mon Sep 17 00:00:00 2001 From: Patrick Hoang Date: Sat, 5 Jul 2025 00:52:43 -0700 Subject: [PATCH 1/7] add audit logs ui for admin dashboard --- api/main_endpoints/models/AuditLog.js | 2 +- api/main_endpoints/routes/AuditLog.js | 19 ++ src/APIFunctions/AuditLog.js | 17 ++ src/Components/Navbar/AdminNavbar.js | 9 + src/Pages/AuditLog/AuditLog.js | 162 ++++++++++++++++++ src/Pages/AuditLog/Components/AuditLogCard.js | 54 ++++++ src/Pages/AuditLog/Components/Error.js | 15 ++ .../Components/FilterActivityTypes.js | 53 ++++++ src/Pages/AuditLog/Components/FilterName.js | 16 ++ src/Pages/AuditLog/Components/Loading.js | 15 ++ src/Pages/AuditLog/Components/Pagination.js | 66 +++++++ .../AuditLog/Components/RefreshButton.js | 22 +++ src/Pages/AuditLog/utils/activityTypes.js | 5 + src/Pages/AuditLog/utils/formatDetails.js | 16 ++ src/Pages/AuditLog/utils/formatTimestamp.js | 11 ++ .../AuditLog/utils/getActionDescription.js | 29 ++++ src/Routing.js | 9 + 17 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 api/main_endpoints/routes/AuditLog.js create mode 100644 src/APIFunctions/AuditLog.js create mode 100644 src/Pages/AuditLog/AuditLog.js create mode 100644 src/Pages/AuditLog/Components/AuditLogCard.js create mode 100644 src/Pages/AuditLog/Components/Error.js create mode 100644 src/Pages/AuditLog/Components/FilterActivityTypes.js create mode 100644 src/Pages/AuditLog/Components/FilterName.js create mode 100644 src/Pages/AuditLog/Components/Loading.js create mode 100644 src/Pages/AuditLog/Components/Pagination.js create mode 100644 src/Pages/AuditLog/Components/RefreshButton.js create mode 100644 src/Pages/AuditLog/utils/activityTypes.js create mode 100644 src/Pages/AuditLog/utils/formatDetails.js create mode 100644 src/Pages/AuditLog/utils/formatTimestamp.js create mode 100644 src/Pages/AuditLog/utils/getActionDescription.js diff --git a/api/main_endpoints/models/AuditLog.js b/api/main_endpoints/models/AuditLog.js index 1618b5d8f..03467cf21 100644 --- a/api/main_endpoints/models/AuditLog.js +++ b/api/main_endpoints/models/AuditLog.js @@ -6,7 +6,7 @@ const AuditLogSchema = new Schema( { userId: { type: Schema.Types.ObjectId, - ref: 'User', + ref: 'User', // references 'User' collections based on the userId required: true, }, action: { diff --git a/api/main_endpoints/routes/AuditLog.js b/api/main_endpoints/routes/AuditLog.js new file mode 100644 index 000000000..23e44ffa4 --- /dev/null +++ b/api/main_endpoints/routes/AuditLog.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); +const AuditLog = require('../models/AuditLog'); +const { + OK, + BAD_REQUEST, + NOT_FOUND +} = require('../../util/constants').STATUS_CODES; + +router.get('/getAuditLogs', (req, res) => { + AuditLog.find() + .populate('userId', 'firstName lastName') // joins User collection based on the userId + .then(items => res.status(OK).send(items)) + .catch(error => { + res.sendStatus(BAD_REQUEST); + }); +}); + +module.exports = router; diff --git a/src/APIFunctions/AuditLog.js b/src/APIFunctions/AuditLog.js new file mode 100644 index 000000000..29b868583 --- /dev/null +++ b/src/APIFunctions/AuditLog.js @@ -0,0 +1,17 @@ +import axios from 'axios'; +import { ApiResponse } from './ApiResponses'; +import { BASE_API_URL } from '../Enums'; + +export async function getAllLogs() { + let status = new ApiResponse(); + await axios + .get(BASE_API_URL + '/api/AuditLog/getAuditLogs') + .then(res => { + status.responseData = res.data; + }) + .catch(err => { + status.responseData = err; + status.error = true; + }); + return status; +} diff --git a/src/Components/Navbar/AdminNavbar.js b/src/Components/Navbar/AdminNavbar.js index a1790597b..2c701c0dd 100644 --- a/src/Components/Navbar/AdminNavbar.js +++ b/src/Components/Navbar/AdminNavbar.js @@ -89,6 +89,15 @@ export default function UserNavBar(props) { ) }, + { + title: 'Audit Logs', + route: '/audit-logs', + icon: ( + + + + ) + }, ]; const renderRoutesForNavbar = (navbarLinks) => { diff --git a/src/Pages/AuditLog/AuditLog.js b/src/Pages/AuditLog/AuditLog.js new file mode 100644 index 000000000..f669b55fc --- /dev/null +++ b/src/Pages/AuditLog/AuditLog.js @@ -0,0 +1,162 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { getAllLogs } from '../../APIFunctions/AuditLog'; +import Loading from './Components/Loading'; +import Error from './Components/Error'; +import FilterActivityTypes from './Components/FilterActivityTypes'; +import RefreshButton from './Components/RefreshButton'; +import FilterName from './Components/FilterName'; +import Pagination from './Components/Pagination'; +import AuditLogCard from './Components/AuditLogCard'; + +export default function AuditLogPage() { + const [auditLogs, setAuditLogs] = useState([]); + const [filteredLogs, setFilteredLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [nameFilter, setNameFilter] = useState(''); + const [activityFilters, setActivityFilters] = useState([]); + + const debounceRef = useRef(); + const [currentPage, setCurrentPage] = useState(1); + + const getAuditLogsFromDB = async () => { + try { + setLoading(true); + const auditLogsFromDB = await getAllLogs(); + if (!auditLogsFromDB.error) { + setAuditLogs(auditLogsFromDB.responseData); + setFilteredLogs(auditLogsFromDB.responseData); + } else { + setError('Failed to load audit logs'); + } + } catch (err) { + setError('Failed to load audit logs'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + getAuditLogsFromDB(); + }, []); + + const debouncedFilter = (callback, delay) => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(callback, delay); + }; + + const applyFilters = useCallback(() => { + let filtered = auditLogs; + + if (nameFilter.trim()) { + filtered = filtered.filter(log => { + const searchTerm = nameFilter.toLowerCase(); + + const userFirstName = log.userId?.firstName?.toLowerCase() || ''; + const userLastName = log.userId?.lastName?.toLowerCase() || ''; + const userFullName = `${userFirstName} ${userLastName}`.trim(); + + return ( + userFirstName.includes(searchTerm) || userLastName.includes(searchTerm) || userFullName.includes(searchTerm) + ); + }); + } + + if (activityFilters.length > 0) { + filtered = filtered.filter(log => activityFilters.includes(log.action)); + } + + setFilteredLogs(filtered); + setCurrentPage(1); + }, [auditLogs, nameFilter, activityFilters]); + + useEffect(() => { + debouncedFilter(applyFilters, 300); + }, [nameFilter, activityFilters, auditLogs, applyFilters]); + + const clearFilters = () => { + setNameFilter(''); + setActivityFilters([]); + setCurrentPage(1); + }; + + const itemsPerPage = 50; + const totalPages = Math.ceil(filteredLogs.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const currentLogs = filteredLogs.slice(startIndex, endIndex); + + const goToPage = page => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }; + + if (loading) { + return ; + } + + if (error) { + return ; + } + + return ( +
+
+

+ Audit Logs +

+
+ Total logs: {auditLogs.length} | Filtered: {filteredLogs.length} | Page {currentPage} of {totalPages} +
+ +
+
+ + +
+ +
+
+
+
+ + {filteredLogs.length === 0 ? ( +
+
📋
+

No audit logs found

+

+ {auditLogs.length === 0 + ? 'There are no audit logs to display at this time.' + : 'No logs match your current filters. Try adjusting your search criteria.'} +

+
+ ) : ( +
+
+ {currentLogs.map((log, index) => ( + + ))} +
+ + {totalPages > 1 && ( + + )} +
+ )} + + {filteredLogs.length > 0 && } +
+ ); +} diff --git a/src/Pages/AuditLog/Components/AuditLogCard.js b/src/Pages/AuditLog/Components/AuditLogCard.js new file mode 100644 index 000000000..ff47f1d42 --- /dev/null +++ b/src/Pages/AuditLog/Components/AuditLogCard.js @@ -0,0 +1,54 @@ +import { getActionDescription } from '../utils/getActionDescription'; +import { formatDetails } from '../utils/formatDetails'; +import { formatTimestamp } from '../utils/formatTimestamp'; + +const AuditLogCard = ({ log, index }) => { + const hasDetails = log => { + return log.details && Object.keys(log.details).length > 0; + }; + + return ( +
+
+
+
+
+
+
+
+

+ + {log.userId ? `${log.userId.firstName} ${log.userId.lastName}` : 'Unknown User'} + {' '} + {getActionDescription(log)} +

+ + {log.documentId && log.documentId !== log.userId && ( +

+ Target: User {log.documentId} +

+ )} +
+
+ +
+ +
+ + {hasDetails(log) &&
{formatDetails(log.details)}
} +
+ +
+ + {log.action.replace(/_/g, ' ')} + +
+
+
+ ); +}; + +export default AuditLogCard; diff --git a/src/Pages/AuditLog/Components/Error.js b/src/Pages/AuditLog/Components/Error.js new file mode 100644 index 000000000..f3ab27d9f --- /dev/null +++ b/src/Pages/AuditLog/Components/Error.js @@ -0,0 +1,15 @@ +const Error = () => { + return ( +
+

+ Audit Logs +

+
+ Error: + {error} +
+
+ ); +}; + +export default Error; diff --git a/src/Pages/AuditLog/Components/FilterActivityTypes.js b/src/Pages/AuditLog/Components/FilterActivityTypes.js new file mode 100644 index 000000000..123acc2b6 --- /dev/null +++ b/src/Pages/AuditLog/Components/FilterActivityTypes.js @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { activityTypes } from '../utils/activityTypes'; + +const FilterActivityTypes = ({ activityFilters, setActivityFilters }) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const toggleActivityFilter = activity => { + setActivityFilters(prev => (prev.includes(activity) ? prev.filter(a => a !== activity) : [...prev, activity])); + }; + + return ( +
+ + + + {isDropdownOpen && ( +
+
+ {activityTypes.map(activity => ( + + ))} +
+
+ )} +
+ ); +}; + +export default FilterActivityTypes; diff --git a/src/Pages/AuditLog/Components/FilterName.js b/src/Pages/AuditLog/Components/FilterName.js new file mode 100644 index 000000000..26883c644 --- /dev/null +++ b/src/Pages/AuditLog/Components/FilterName.js @@ -0,0 +1,16 @@ +const FilterName = ({ nameFilter, setNameFilter }) => { + return ( +
+ + setNameFilter(e.target.value)} + placeholder='Enter first name or last name...' + className='w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500' + /> +
+ ); +}; + +export default FilterName; diff --git a/src/Pages/AuditLog/Components/Loading.js b/src/Pages/AuditLog/Components/Loading.js new file mode 100644 index 000000000..55da330fa --- /dev/null +++ b/src/Pages/AuditLog/Components/Loading.js @@ -0,0 +1,15 @@ +const Loading = () => { + return ( +
+

+ Audit Logs +

+
+
+ Loading audit logs... +
+
+ ); +}; + +export default Loading; diff --git a/src/Pages/AuditLog/Components/Pagination.js b/src/Pages/AuditLog/Components/Pagination.js new file mode 100644 index 000000000..daf2f878a --- /dev/null +++ b/src/Pages/AuditLog/Components/Pagination.js @@ -0,0 +1,66 @@ +const Pagination = ({ currentPage, totalPages, goToPage, startIndex, endIndex, filteredLogs }) => { + return ( +
+
+ Showing {startIndex + 1} to {Math.min(endIndex, filteredLogs.length)} of {filteredLogs.length} results +
+ +
+ + +
+ {[...Array(Math.min(5, totalPages))].map((_, i) => { + let pageNum; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (currentPage <= 3) { + pageNum = i + 1; + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = currentPage - 2 + i; + } + + return ( + + ); + })} +
+ + +
+
+ ); +}; + +export default Pagination; diff --git a/src/Pages/AuditLog/Components/RefreshButton.js b/src/Pages/AuditLog/Components/RefreshButton.js new file mode 100644 index 000000000..80fcfee78 --- /dev/null +++ b/src/Pages/AuditLog/Components/RefreshButton.js @@ -0,0 +1,22 @@ +const RefreshButton = ({ getAuditLogsFromDB }) => { + return ( +
+ +
+ ); +}; + +export default RefreshButton; diff --git a/src/Pages/AuditLog/utils/activityTypes.js b/src/Pages/AuditLog/utils/activityTypes.js new file mode 100644 index 000000000..d499cafd8 --- /dev/null +++ b/src/Pages/AuditLog/utils/activityTypes.js @@ -0,0 +1,5 @@ +export const activityTypes = [ + 'SIGN_UP', 'LOG_IN', 'UPDATE_USER', 'DELETE_USER', + 'PRINT_PAGE', 'ACCESS_DOOR', 'CREATE_MESSAGE', 'DELETE_MESSAGE', + 'VERIFY_EMAIL', 'EMAIL_SENT', 'CHANGE_PW', 'RESET_PW' +]; diff --git a/src/Pages/AuditLog/utils/formatDetails.js b/src/Pages/AuditLog/utils/formatDetails.js new file mode 100644 index 000000000..13a1bc1c0 --- /dev/null +++ b/src/Pages/AuditLog/utils/formatDetails.js @@ -0,0 +1,16 @@ +export const formatDetails = (details) => { + if (!details || Object.keys(details).length === 0) return null; + + return ( +
+

Details:

+
+ {Object.entries(details).map(([key, value]) => ( +
+ {key}: {String(value)} +
+ ))} +
+
+ ); +}; diff --git a/src/Pages/AuditLog/utils/formatTimestamp.js b/src/Pages/AuditLog/utils/formatTimestamp.js new file mode 100644 index 000000000..297872e26 --- /dev/null +++ b/src/Pages/AuditLog/utils/formatTimestamp.js @@ -0,0 +1,11 @@ +export const formatTimestamp = (timestamp) => { + return new Date(timestamp).toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short' + }); +}; diff --git a/src/Pages/AuditLog/utils/getActionDescription.js b/src/Pages/AuditLog/utils/getActionDescription.js new file mode 100644 index 000000000..e30c80e62 --- /dev/null +++ b/src/Pages/AuditLog/utils/getActionDescription.js @@ -0,0 +1,29 @@ +export const getActionDescription = (log) => { + const action = log.action; + switch (action) { + case 'SIGN_UP': + return 'signed up for an account'; + case 'LOG_IN': + return 'logged into the system'; + case 'UPDATE_USER': + if (log.documentId && log.documentId !== log.userId) { + return 'updated another user\'s account information'; + } + return 'updated their account information'; + case 'DELETE_USER': + if (log.documentId && log.documentId !== log.userId) { + return 'deleted another user account'; + } + return 'deleted their account'; + case 'PRINT_PAGE': + return 'printed a page'; + case 'ACCESS_DOOR': + return 'accessed a door'; + case 'CREATE_MESSAGE': + return 'created a message'; + case 'DELETE_MESSAGE': + return 'deleted a message'; + default: + return `performed action: ${action.toLowerCase().replace(/_/g, ' ')}`; + } +}; diff --git a/src/Routing.js b/src/Routing.js index 0d5b1eabf..a90d7036d 100644 --- a/src/Routing.js +++ b/src/Routing.js @@ -32,6 +32,8 @@ import Messaging from './Pages/Messaging/Messaging.js'; import CardReader from './Pages/CardReader/CardReader.js'; +import AuditLogsPage from './Pages/AuditLog/AuditLog.js'; + import { useUser } from './Components/context/UserContext'; export default function Routing({ appProps }) { @@ -143,6 +145,13 @@ export default function Routing({ appProps }) { redirect: '/', inAdminNavbar: true }, + { + Component: AuditLogsPage, + path: '/audit-logs', + allowedIf: userIsOfficerOrAdmin, + redirect: '/', + inAdminNavbar: true + } ]; const signedOutRoutes = [ { Component: Home, path: '/' }, From 88ff6d5c9d29ca9b3798cda545fe92003c50a49b Mon Sep 17 00:00:00 2001 From: Patrick Hoang Date: Sat, 5 Jul 2025 01:06:57 -0700 Subject: [PATCH 2/7] add AuditLogs route --- src/Routes.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Routes.js b/src/Routes.js index 56ccf0e58..3b75c1723 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -17,6 +17,7 @@ import sendUnsubscribeEmail from './Pages/Profile/admin/SendUnsubscribeEmail.js' import Messaging from './Pages/Messaging/Messaging.js'; import Home from './Pages/Home/Home.js'; import CardReader from './Pages/CardReader/CardReader.js'; +import AuditLogsPage from './Pages/AuditLog/AuditLog.js'; // Declare an enum for permission check export const allowedIf = { @@ -147,6 +148,13 @@ export const officerOrAdminRoutes = [ redirect: '/', inAdminNavbar: true }, + { + Component: AuditLogsPage, + path: '/audit-logs', + allowedIf: userIsOfficerOrAdmin, + redirect: '/', + inAdminNavbar: true + }, ...memberRoutes, ]; From 981c29cb8d31f596cb167837076bd19dfe5b6494 Mon Sep 17 00:00:00 2001 From: Patrick Hoang Date: Sat, 5 Jul 2025 01:12:55 -0700 Subject: [PATCH 3/7] minor routing fix --- src/Routes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Routes.js b/src/Routes.js index 3b75c1723..61aa37ab7 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -151,7 +151,7 @@ export const officerOrAdminRoutes = [ { Component: AuditLogsPage, path: '/audit-logs', - allowedIf: userIsOfficerOrAdmin, + allowedIf: allowedIf.OFFICER_OR_ADMIN, redirect: '/', inAdminNavbar: true }, From a15eeab672c26d97f4a4ac251df21da28d61ef2c Mon Sep 17 00:00:00 2001 From: Patrick Hoang Date: Mon, 7 Jul 2025 00:00:31 -0700 Subject: [PATCH 4/7] add token verification and pagination --- api/main_endpoints/routes/AuditLog.js | 87 +++++++++++++++++-- src/APIFunctions/AuditLog.js | 37 +++++--- src/Pages/AuditLog/AuditLog.js | 87 +++++++------------ src/Pages/AuditLog/Components/AuditLogCard.js | 3 +- src/Pages/AuditLog/Components/Pagination.js | 7 +- 5 files changed, 143 insertions(+), 78 deletions(-) diff --git a/api/main_endpoints/routes/AuditLog.js b/api/main_endpoints/routes/AuditLog.js index 23e44ffa4..fd242a4f9 100644 --- a/api/main_endpoints/routes/AuditLog.js +++ b/api/main_endpoints/routes/AuditLog.js @@ -4,16 +4,87 @@ const AuditLog = require('../models/AuditLog'); const { OK, BAD_REQUEST, - NOT_FOUND + UNAUTHORIZED } = require('../../util/constants').STATUS_CODES; -router.get('/getAuditLogs', (req, res) => { - AuditLog.find() - .populate('userId', 'firstName lastName') // joins User collection based on the userId - .then(items => res.status(OK).send(items)) - .catch(error => { - res.sendStatus(BAD_REQUEST); - }); +const { + decodeToken, + checkIfTokenSent +} = require('../util/token-functions.js'); + +const logger = require('../../util/logger'); + +router.get('/getAuditLogs', async (req, res) => { + if (!checkIfTokenSent(req)) { + logger.warn('/getAuditLogs was requested without a token'); + return res.sendStatus(UNAUTHORIZED); + } + + const decodedPayload = await decodeToken(req) + + if (!decodedPayload) { + logger.warn('/getAuditLogs was requested with an invalid token'); + return res.sendStatus(UNAUTHORIZED); + } + + if (decodedPayload.accessLevel < 2) { + return res.sendStatus(UNAUTHORIZED); + } + + const itemsPerPage = 50; + const page = parseInt(req.query.page) || 1; + const skip = (page - 1) * itemsPerPage; + + const rawActions = req.query.action // from URL, structure is: "?action=LOG_IN,SIGN_UP,PRINT_PAGE" + const actions = rawActions ? rawActions.split(','): [] // converts to [LOG_IN, SIGN_UP, PRINT_PAGE] + + const nameQuery = req.query.name?.trim().replace(/\s+/g, ' '); + + const query = {} + if (actions.length > 0) { + query.action = {$in: actions} + } + + try { + + if (actions.length > 0) { + query.action = {$in: actions} + } + + if (nameQuery) { + const allLogs = await AuditLog.find(query) + .populate('userId', 'firstName lastName') + .sort({createdAt: -1}); + + // filter by first name, last name, or full name + const filteredLogs = allLogs.filter(log => { + if (!log.userId) return false; + const firstName = log.userId.firstName || ''; + const lastName = log.userId.lastName || ''; + const fullName = `${firstName} ${lastName}`; + + return fullName.toLowerCase().includes(nameQuery.toLowerCase()); + }); + + const totalLogs = filteredLogs.length; + const items = filteredLogs.slice(skip, skip + itemsPerPage); + + res.status(OK).send({items, totalLogs}); + + } else { + const items = await AuditLog.find(query) + .populate('userId', 'firstName lastName') + .skip(skip) + .limit(itemsPerPage) + .sort({createdAt: -1}); + + const totalLogs = await AuditLog.countDocuments(query); + res.status(OK).send({items, totalLogs}); + } + } catch (error) { + logger.error('Failed to fetch audit logs:', error); + res.sendStatus(BAD_REQUEST); + } }); module.exports = router; diff --git a/src/APIFunctions/AuditLog.js b/src/APIFunctions/AuditLog.js index 29b868583..6f39664fb 100644 --- a/src/APIFunctions/AuditLog.js +++ b/src/APIFunctions/AuditLog.js @@ -2,16 +2,33 @@ import axios from 'axios'; import { ApiResponse } from './ApiResponses'; import { BASE_API_URL } from '../Enums'; -export async function getAllLogs() { - let status = new ApiResponse(); - await axios - .get(BASE_API_URL + '/api/AuditLog/getAuditLogs') - .then(res => { - status.responseData = res.data; - }) - .catch(err => { - status.responseData = err; - status.error = true; +export async function getAllLogs(page, actionFilter, nameFilter, token) { + const status = new ApiResponse(); + const url = new URL('/api/AuditLog/getAuditLogs', BASE_API_URL); + + if (page) { + url.searchParams.append('page', page); + } + + if (Array.isArray(actionFilter) && actionFilter.length > 0) { + url.searchParams.append('action', actionFilter.join(',')); + } + + if (nameFilter) { + url.searchParams.append('name', nameFilter); + } + + try { + const res = await axios.get(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, }); + status.responseData = res.data; + } catch (err) { + status.responseData = err; + status.error = true; + } + return status; } diff --git a/src/Pages/AuditLog/AuditLog.js b/src/Pages/AuditLog/AuditLog.js index f669b55fc..2a63e2754 100644 --- a/src/Pages/AuditLog/AuditLog.js +++ b/src/Pages/AuditLog/AuditLog.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { getAllLogs } from '../../APIFunctions/AuditLog'; import Loading from './Components/Loading'; import Error from './Components/Error'; @@ -7,26 +7,27 @@ import RefreshButton from './Components/RefreshButton'; import FilterName from './Components/FilterName'; import Pagination from './Components/Pagination'; import AuditLogCard from './Components/AuditLogCard'; +import { useUser } from '../../Components/context/UserContext'; export default function AuditLogPage() { const [auditLogs, setAuditLogs] = useState([]); - const [filteredLogs, setFilteredLogs] = useState([]); + const [totalLogs, setTotalLogs] = useState(0) const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [nameFilter, setNameFilter] = useState(''); const [activityFilters, setActivityFilters] = useState([]); - const debounceRef = useRef(); const [currentPage, setCurrentPage] = useState(1); + const user = useUser() const getAuditLogsFromDB = async () => { try { setLoading(true); - const auditLogsFromDB = await getAllLogs(); + const auditLogsFromDB = await getAllLogs(currentPage, activityFilters, nameFilter, user.user.token); if (!auditLogsFromDB.error) { - setAuditLogs(auditLogsFromDB.responseData); - setFilteredLogs(auditLogsFromDB.responseData); + setAuditLogs(auditLogsFromDB.responseData.items); + setTotalLogs(auditLogsFromDB.responseData.totalLogs) } else { setError('Failed to load audit logs'); } @@ -39,53 +40,23 @@ export default function AuditLogPage() { useEffect(() => { getAuditLogsFromDB(); - }, []); + }, [currentPage]); - const debouncedFilter = (callback, delay) => { - clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(callback, delay); + const applyFilters = () => { + setCurrentPage(1); // reset to first page when applying filters + getAuditLogsFromDB(); }; - const applyFilters = useCallback(() => { - let filtered = auditLogs; - - if (nameFilter.trim()) { - filtered = filtered.filter(log => { - const searchTerm = nameFilter.toLowerCase(); - - const userFirstName = log.userId?.firstName?.toLowerCase() || ''; - const userLastName = log.userId?.lastName?.toLowerCase() || ''; - const userFullName = `${userFirstName} ${userLastName}`.trim(); - - return ( - userFirstName.includes(searchTerm) || userLastName.includes(searchTerm) || userFullName.includes(searchTerm) - ); - }); - } - - if (activityFilters.length > 0) { - filtered = filtered.filter(log => activityFilters.includes(log.action)); - } - - setFilteredLogs(filtered); - setCurrentPage(1); - }, [auditLogs, nameFilter, activityFilters]); - - useEffect(() => { - debouncedFilter(applyFilters, 300); - }, [nameFilter, activityFilters, auditLogs, applyFilters]); - const clearFilters = () => { setNameFilter(''); setActivityFilters([]); setCurrentPage(1); + getAuditLogsFromDB(); }; const itemsPerPage = 50; - const totalPages = Math.ceil(filteredLogs.length / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const currentLogs = filteredLogs.slice(startIndex, endIndex); + const totalPages = Math.ceil(totalLogs / itemsPerPage); + const currentLogs = auditLogs; const goToPage = page => { setCurrentPage(Math.max(1, Math.min(page, totalPages))); @@ -98,7 +69,7 @@ export default function AuditLogPage() { if (error) { return ; } - + console.log(auditLogs) return (
@@ -106,14 +77,20 @@ export default function AuditLogPage() { Audit Logs
- Total logs: {auditLogs.length} | Filtered: {filteredLogs.length} | Page {currentPage} of {totalPages} + Total logs: {totalLogs} | Showing: {currentLogs.length} | Page {currentPage} of {totalPages}
-
+
+
- {filteredLogs.length === 0 ? ( + {currentLogs.length === 0 ? (
-
📋

No audit logs found

- {auditLogs.length === 0 - ? 'There are no audit logs to display at this time.' - : 'No logs match your current filters. Try adjusting your search criteria.'} + {(nameFilter.trim() || activityFilters.length > 0) + ? 'No logs match your current filters. Try adjusting your search criteria.' + : 'There are no audit logs to display at this time.'}

) : (
{currentLogs.map((log, index) => ( - + ))}
@@ -148,15 +124,14 @@ export default function AuditLogPage() { currentPage={currentPage} totalPages={totalPages} goToPage={goToPage} - startIndex={startIndex} - endIndex={endIndex} - filteredLogs={filteredLogs} + itemsPerPage={itemsPerPage} + totalLogs={totalLogs} /> )}
)} - {filteredLogs.length > 0 && } + {currentLogs.length > 0 && }
); } diff --git a/src/Pages/AuditLog/Components/AuditLogCard.js b/src/Pages/AuditLog/Components/AuditLogCard.js index ff47f1d42..f61005ea4 100644 --- a/src/Pages/AuditLog/Components/AuditLogCard.js +++ b/src/Pages/AuditLog/Components/AuditLogCard.js @@ -2,14 +2,13 @@ import { getActionDescription } from '../utils/getActionDescription'; import { formatDetails } from '../utils/formatDetails'; import { formatTimestamp } from '../utils/formatTimestamp'; -const AuditLogCard = ({ log, index }) => { +const AuditLogCard = ({ log }) => { const hasDetails = log => { return log.details && Object.keys(log.details).length > 0; }; return (
diff --git a/src/Pages/AuditLog/Components/Pagination.js b/src/Pages/AuditLog/Components/Pagination.js index daf2f878a..bacad1cb9 100644 --- a/src/Pages/AuditLog/Components/Pagination.js +++ b/src/Pages/AuditLog/Components/Pagination.js @@ -1,8 +1,11 @@ -const Pagination = ({ currentPage, totalPages, goToPage, startIndex, endIndex, filteredLogs }) => { +const Pagination = ({ currentPage, totalPages, goToPage, itemsPerPage, totalLogs }) => { + const startIndex = (currentPage - 1) * itemsPerPage + 1; + const endIndex = Math.min(currentPage * itemsPerPage, totalLogs); + return (
- Showing {startIndex + 1} to {Math.min(endIndex, filteredLogs.length)} of {filteredLogs.length} results + Showing {startIndex} to {endIndex} of {totalLogs} results
From d48c7303313eba91ec6550aefb553d90f656ff53 Mon Sep 17 00:00:00 2001 From: Patrick Hoang Date: Mon, 7 Jul 2025 00:02:39 -0700 Subject: [PATCH 5/7] lint fixes --- api/main_endpoints/routes/AuditLog.js | 24 ++++++++++----------- src/Pages/AuditLog/AuditLog.js | 12 +++++------ src/Pages/AuditLog/Components/Pagination.js | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/api/main_endpoints/routes/AuditLog.js b/api/main_endpoints/routes/AuditLog.js index fd242a4f9..8a6f97085 100644 --- a/api/main_endpoints/routes/AuditLog.js +++ b/api/main_endpoints/routes/AuditLog.js @@ -19,8 +19,8 @@ router.get('/getAuditLogs', async (req, res) => { logger.warn('/getAuditLogs was requested without a token'); return res.sendStatus(UNAUTHORIZED); } - - const decodedPayload = await decodeToken(req) + + const decodedPayload = await decodeToken(req); if (!decodedPayload) { logger.warn('/getAuditLogs was requested with an invalid token'); @@ -32,23 +32,23 @@ router.get('/getAuditLogs', async (req, res) => { } const itemsPerPage = 50; - const page = parseInt(req.query.page) || 1; + const page = parseInt(req.query.page) || 1; const skip = (page - 1) * itemsPerPage; - const rawActions = req.query.action // from URL, structure is: "?action=LOG_IN,SIGN_UP,PRINT_PAGE" - const actions = rawActions ? rawActions.split(','): [] // converts to [LOG_IN, SIGN_UP, PRINT_PAGE] - + const rawActions = req.query.action; // from URL, structure is: "?action=LOG_IN,SIGN_UP,PRINT_PAGE" + const actions = rawActions ? rawActions.split(',') : []; // converts to [LOG_IN, SIGN_UP, PRINT_PAGE] + const nameQuery = req.query.name?.trim().replace(/\s+/g, ' '); - - const query = {} + + const query = {}; if (actions.length > 0) { - query.action = {$in: actions} + query.action = {$in: actions}; } try { if (actions.length > 0) { - query.action = {$in: actions} + query.action = {$in: actions}; } if (nameQuery) { @@ -62,7 +62,7 @@ router.get('/getAuditLogs', async (req, res) => { const firstName = log.userId.firstName || ''; const lastName = log.userId.lastName || ''; const fullName = `${firstName} ${lastName}`; - + return fullName.toLowerCase().includes(nameQuery.toLowerCase()); }); @@ -70,7 +70,7 @@ router.get('/getAuditLogs', async (req, res) => { const items = filteredLogs.slice(skip, skip + itemsPerPage); res.status(OK).send({items, totalLogs}); - + } else { const items = await AuditLog.find(query) .populate('userId', 'firstName lastName') diff --git a/src/Pages/AuditLog/AuditLog.js b/src/Pages/AuditLog/AuditLog.js index 2a63e2754..949a00651 100644 --- a/src/Pages/AuditLog/AuditLog.js +++ b/src/Pages/AuditLog/AuditLog.js @@ -11,7 +11,7 @@ import { useUser } from '../../Components/context/UserContext'; export default function AuditLogPage() { const [auditLogs, setAuditLogs] = useState([]); - const [totalLogs, setTotalLogs] = useState(0) + const [totalLogs, setTotalLogs] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -20,14 +20,14 @@ export default function AuditLogPage() { const [currentPage, setCurrentPage] = useState(1); - const user = useUser() + const user = useUser(); const getAuditLogsFromDB = async () => { try { setLoading(true); const auditLogsFromDB = await getAllLogs(currentPage, activityFilters, nameFilter, user.user.token); if (!auditLogsFromDB.error) { setAuditLogs(auditLogsFromDB.responseData.items); - setTotalLogs(auditLogsFromDB.responseData.totalLogs) + setTotalLogs(auditLogsFromDB.responseData.totalLogs); } else { setError('Failed to load audit logs'); } @@ -44,14 +44,14 @@ export default function AuditLogPage() { const applyFilters = () => { setCurrentPage(1); // reset to first page when applying filters - getAuditLogsFromDB(); + getAuditLogsFromDB(); }; const clearFilters = () => { setNameFilter(''); setActivityFilters([]); setCurrentPage(1); - getAuditLogsFromDB(); + getAuditLogsFromDB(); }; const itemsPerPage = 50; @@ -69,7 +69,7 @@ export default function AuditLogPage() { if (error) { return ; } - console.log(auditLogs) + return (
diff --git a/src/Pages/AuditLog/Components/Pagination.js b/src/Pages/AuditLog/Components/Pagination.js index bacad1cb9..10f740724 100644 --- a/src/Pages/AuditLog/Components/Pagination.js +++ b/src/Pages/AuditLog/Components/Pagination.js @@ -1,7 +1,7 @@ const Pagination = ({ currentPage, totalPages, goToPage, itemsPerPage, totalLogs }) => { const startIndex = (currentPage - 1) * itemsPerPage + 1; const endIndex = Math.min(currentPage * itemsPerPage, totalLogs); - + return (
From ad9169686bb5cadec384cf84fdd0c3d7dd38e1c4 Mon Sep 17 00:00:00 2001 From: Patrick Hoang Date: Tue, 8 Jul 2025 11:40:45 -0700 Subject: [PATCH 6/7] implement filtering in mongodb query instead of JS --- api/main_endpoints/routes/AuditLog.js | 65 +++++++------------ src/APIFunctions/AuditLog.js | 10 ++- src/Pages/AuditLog/AuditLog.js | 24 +++++-- .../{FilterName.js => FirstNameFilter.js} | 10 +-- .../AuditLog/Components/LastNameFilter.js | 16 +++++ 5 files changed, 70 insertions(+), 55 deletions(-) rename src/Pages/AuditLog/Components/{FilterName.js => FirstNameFilter.js} (60%) create mode 100644 src/Pages/AuditLog/Components/LastNameFilter.js diff --git a/api/main_endpoints/routes/AuditLog.js b/api/main_endpoints/routes/AuditLog.js index 8a6f97085..db06be505 100644 --- a/api/main_endpoints/routes/AuditLog.js +++ b/api/main_endpoints/routes/AuditLog.js @@ -1,18 +1,12 @@ const express = require('express'); const router = express.Router(); const AuditLog = require('../models/AuditLog'); -const { - OK, - BAD_REQUEST, - UNAUTHORIZED -} = require('../../util/constants').STATUS_CODES; +const { OK, BAD_REQUEST, UNAUTHORIZED } = require('../../util/constants').STATUS_CODES; -const { - decodeToken, - checkIfTokenSent -} = require('../util/token-functions.js'); +const { decodeToken, checkIfTokenSent } = require('../util/token-functions.js'); const logger = require('../../util/logger'); +const User = require('../models/User.js'); router.get('/getAuditLogs', async (req, res) => { if (!checkIfTokenSent(req)) { @@ -28,6 +22,7 @@ router.get('/getAuditLogs', async (req, res) => { } if (decodedPayload.accessLevel < 2) { + logger.warn('/getAuditLogs was requested with an inappropriate access level'); return res.sendStatus(UNAUTHORIZED); } @@ -38,49 +33,39 @@ router.get('/getAuditLogs', async (req, res) => { const rawActions = req.query.action; // from URL, structure is: "?action=LOG_IN,SIGN_UP,PRINT_PAGE" const actions = rawActions ? rawActions.split(',') : []; // converts to [LOG_IN, SIGN_UP, PRINT_PAGE] - const nameQuery = req.query.name?.trim().replace(/\s+/g, ' '); + const firstNameQuery = req.query.firstName?.trim(); + const lastNameQuery = req.query.lastName?.trim(); const query = {}; if (actions.length > 0) { - query.action = {$in: actions}; + query.action = { $in: actions }; } try { + if (firstNameQuery || lastNameQuery) { + const userFilter = {}; - if (actions.length > 0) { - query.action = {$in: actions}; - } - - if (nameQuery) { - const allLogs = await AuditLog.find(query) - .populate('userId', 'firstName lastName') - .sort({createdAt: -1}); - - // filter by first name, last name, or full name - const filteredLogs = allLogs.filter(log => { - if (!log.userId) return false; - const firstName = log.userId.firstName || ''; - const lastName = log.userId.lastName || ''; - const fullName = `${firstName} ${lastName}`; + if (firstNameQuery) userFilter.firstName = new RegExp(firstNameQuery, 'i'); + if (lastNameQuery) userFilter.lastName = new RegExp(lastNameQuery, 'i'); - return fullName.toLowerCase().includes(nameQuery.toLowerCase()); - }); + const users = await User.find(userFilter).select('_id'); + const userIds = users.map(u => u._id); - const totalLogs = filteredLogs.length; - const items = filteredLogs.slice(skip, skip + itemsPerPage); + if (userIds.length === 0) { + return res.status(OK).send({ items: [], totalLogs: 0 }); + } - res.status(OK).send({items, totalLogs}); + query.userId = { $in: userIds }; + } - } else { - const items = await AuditLog.find(query) - .populate('userId', 'firstName lastName') - .skip(skip) - .limit(itemsPerPage) - .sort({createdAt: -1}); + const items = await AuditLog.find(query) + .populate('userId', 'firstName lastName') + .skip(skip) + .limit(itemsPerPage) + .sort({ createdAt: -1 }); - const totalLogs = await AuditLog.countDocuments(query); - res.status(OK).send({items, totalLogs}); - } + const totalLogs = await AuditLog.countDocuments(query); + res.status(OK).send({ items, totalLogs }); } catch (error) { logger.error('Failed to fetch audit logs:', error); res.sendStatus(BAD_REQUEST); diff --git a/src/APIFunctions/AuditLog.js b/src/APIFunctions/AuditLog.js index 6f39664fb..11e9976d0 100644 --- a/src/APIFunctions/AuditLog.js +++ b/src/APIFunctions/AuditLog.js @@ -2,7 +2,7 @@ import axios from 'axios'; import { ApiResponse } from './ApiResponses'; import { BASE_API_URL } from '../Enums'; -export async function getAllLogs(page, actionFilter, nameFilter, token) { +export async function getAllLogs(page, actionFilter, firstNameFilter, lastNameFilter, token) { const status = new ApiResponse(); const url = new URL('/api/AuditLog/getAuditLogs', BASE_API_URL); @@ -14,8 +14,12 @@ export async function getAllLogs(page, actionFilter, nameFilter, token) { url.searchParams.append('action', actionFilter.join(',')); } - if (nameFilter) { - url.searchParams.append('name', nameFilter); + if (firstNameFilter) { + url.searchParams.append('firstName', firstNameFilter); + } + + if (lastNameFilter) { + url.searchParams.append('lastName', lastNameFilter); } try { diff --git a/src/Pages/AuditLog/AuditLog.js b/src/Pages/AuditLog/AuditLog.js index 949a00651..d92129ea3 100644 --- a/src/Pages/AuditLog/AuditLog.js +++ b/src/Pages/AuditLog/AuditLog.js @@ -4,10 +4,11 @@ import Loading from './Components/Loading'; import Error from './Components/Error'; import FilterActivityTypes from './Components/FilterActivityTypes'; import RefreshButton from './Components/RefreshButton'; -import FilterName from './Components/FilterName'; +import FirstNameFilter from './Components/FirstNameFilter'; import Pagination from './Components/Pagination'; import AuditLogCard from './Components/AuditLogCard'; import { useUser } from '../../Components/context/UserContext'; +import LastNameFilter from './Components/LastNameFilter'; export default function AuditLogPage() { const [auditLogs, setAuditLogs] = useState([]); @@ -15,7 +16,8 @@ export default function AuditLogPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const [nameFilter, setNameFilter] = useState(''); + const [firstNameFilter, setFirstNameFilter] = useState(''); + const [lastNameFilter, setLastNameFilter] = useState(''); const [activityFilters, setActivityFilters] = useState([]); const [currentPage, setCurrentPage] = useState(1); @@ -24,7 +26,13 @@ export default function AuditLogPage() { const getAuditLogsFromDB = async () => { try { setLoading(true); - const auditLogsFromDB = await getAllLogs(currentPage, activityFilters, nameFilter, user.user.token); + const auditLogsFromDB = await getAllLogs( + currentPage, + activityFilters, + firstNameFilter, + lastNameFilter, + user.user.token + ); if (!auditLogsFromDB.error) { setAuditLogs(auditLogsFromDB.responseData.items); setTotalLogs(auditLogsFromDB.responseData.totalLogs); @@ -48,7 +56,8 @@ export default function AuditLogPage() { }; const clearFilters = () => { - setNameFilter(''); + setFirstNameFilter(''); + setLastNameFilter(''); setActivityFilters([]); setCurrentPage(1); getAuditLogsFromDB(); @@ -82,7 +91,8 @@ export default function AuditLogPage() {
- + +