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..db06be505 --- /dev/null +++ b/api/main_endpoints/routes/AuditLog.js @@ -0,0 +1,75 @@ +const express = require('express'); +const router = express.Router(); +const AuditLog = require('../models/AuditLog'); +const { OK, BAD_REQUEST, UNAUTHORIZED } = require('../../util/constants').STATUS_CODES; + +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)) { + 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) { + logger.warn('/getAuditLogs was requested with an inappropriate access level'); + 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 firstNameQuery = req.query.firstName?.trim(); + const lastNameQuery = req.query.lastName?.trim(); + + const query = {}; + if (actions.length > 0) { + query.action = { $in: actions }; + } + + try { + if (firstNameQuery || lastNameQuery) { + const userFilter = {}; + + if (firstNameQuery) userFilter.firstName = new RegExp(firstNameQuery, 'i'); + if (lastNameQuery) userFilter.lastName = new RegExp(lastNameQuery, 'i'); + + const users = await User.find(userFilter).select('_id'); + const userIds = users.map(u => u._id); + + if (userIds.length === 0) { + return res.status(OK).send({ items: [], totalLogs: 0 }); + } + + query.userId = { $in: userIds }; + } + + 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 new file mode 100644 index 000000000..11e9976d0 --- /dev/null +++ b/src/APIFunctions/AuditLog.js @@ -0,0 +1,38 @@ +import axios from 'axios'; +import { ApiResponse } from './ApiResponses'; +import { BASE_API_URL } from '../Enums'; + +export async function getAllLogs(page, actionFilter, firstNameFilter, lastNameFilter, 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 (firstNameFilter) { + url.searchParams.append('firstName', firstNameFilter); + } + + if (lastNameFilter) { + url.searchParams.append('lastName', lastNameFilter); + } + + 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/Components/Navbar/AdminNavbar.js b/src/Components/Navbar/AdminNavbar.js index eb9494c5f..0a4fe52bc 100644 --- a/src/Components/Navbar/AdminNavbar.js +++ b/src/Components/Navbar/AdminNavbar.js @@ -91,6 +91,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..d92129ea3 --- /dev/null +++ b/src/Pages/AuditLog/AuditLog.js @@ -0,0 +1,147 @@ +import { useState, useEffect } 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 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([]); + const [totalLogs, setTotalLogs] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const [firstNameFilter, setFirstNameFilter] = useState(''); + const [lastNameFilter, setLastNameFilter] = useState(''); + const [activityFilters, setActivityFilters] = useState([]); + + const [currentPage, setCurrentPage] = useState(1); + + const user = useUser(); + const getAuditLogsFromDB = async () => { + try { + setLoading(true); + const auditLogsFromDB = await getAllLogs( + currentPage, + activityFilters, + firstNameFilter, + lastNameFilter, + user.user.token + ); + if (!auditLogsFromDB.error) { + setAuditLogs(auditLogsFromDB.responseData.items); + setTotalLogs(auditLogsFromDB.responseData.totalLogs); + } else { + setError('Failed to load audit logs'); + } + } catch (err) { + setError('Failed to load audit logs'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + getAuditLogsFromDB(); + }, [currentPage]); + + const applyFilters = () => { + setCurrentPage(1); // reset to first page when applying filters + getAuditLogsFromDB(); + }; + + const clearFilters = () => { + setFirstNameFilter(''); + setLastNameFilter(''); + setActivityFilters([]); + setCurrentPage(1); + getAuditLogsFromDB(); + }; + + const itemsPerPage = 50; + const totalPages = Math.ceil(totalLogs / itemsPerPage); + const currentLogs = auditLogs; + + const goToPage = page => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }; + + if (loading) { + return ; + } + + if (error) { + return ; + } + + return ( +
+
+

+ Audit Logs +

+
+ Total logs: {totalLogs} | Showing: {currentLogs.length} | Page {currentPage} of {totalPages} +
+ +
+
+ + + +
+ + +
+
+
+
+ + {currentLogs.length === 0 ? ( +
+

No audit logs found

+

+ {firstNameFilter.trim() || lastNameFilter.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) => ( + + ))} +
+ + {totalPages > 1 && ( + + )} +
+ )} + + {currentLogs.length > 0 && } +
+ ); +} diff --git a/src/Pages/AuditLog/Components/AuditLogCard.js b/src/Pages/AuditLog/Components/AuditLogCard.js new file mode 100644 index 000000000..f61005ea4 --- /dev/null +++ b/src/Pages/AuditLog/Components/AuditLogCard.js @@ -0,0 +1,53 @@ +import { getActionDescription } from '../utils/getActionDescription'; +import { formatDetails } from '../utils/formatDetails'; +import { formatTimestamp } from '../utils/formatTimestamp'; + +const AuditLogCard = ({ log }) => { + 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/FirstNameFilter.js b/src/Pages/AuditLog/Components/FirstNameFilter.js new file mode 100644 index 000000000..f945e7882 --- /dev/null +++ b/src/Pages/AuditLog/Components/FirstNameFilter.js @@ -0,0 +1,16 @@ +const FilterName = ({ firstNameFilter, setFirstNameFilter }) => { + return ( +
+ + setFirstNameFilter(e.target.value)} + placeholder='Enter first 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/LastNameFilter.js b/src/Pages/AuditLog/Components/LastNameFilter.js new file mode 100644 index 000000000..1a552520b --- /dev/null +++ b/src/Pages/AuditLog/Components/LastNameFilter.js @@ -0,0 +1,16 @@ +const LastNameFilter = ({ lastNameFilter, setLastNameFilter }) => { + return ( +
+ + setLastNameFilter(e.target.value)} + placeholder='Enter 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 LastNameFilter; 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..10f740724 --- /dev/null +++ b/src/Pages/AuditLog/Components/Pagination.js @@ -0,0 +1,69 @@ +const Pagination = ({ currentPage, totalPages, goToPage, itemsPerPage, totalLogs }) => { + const startIndex = (currentPage - 1) * itemsPerPage + 1; + const endIndex = Math.min(currentPage * itemsPerPage, totalLogs); + + return ( +
+
+ Showing {startIndex} to {endIndex} of {totalLogs} 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..cdb9aad65 --- /dev/null +++ b/src/Pages/AuditLog/utils/getActionDescription.js @@ -0,0 +1,33 @@ +const simpleActionDescriptions = { + SIGN_UP: 'signed up for an account', + LOG_IN: 'logged into the system', + PRINT_PAGE: 'printed a page', + ACCESS_DOOR: 'accessed a door', + CREATE_MESSAGE: 'created a message', + DELETE_MESSAGE: 'deleted a message', +}; + +export const getActionDescription = (log) => { + const action = log.action; + + // checks if a user updates or deletes ANOTHER user + if (action === 'UPDATE_USER') { + if (log.documentId && log.documentId !== log.userId) { + return 'updated another user\'s account information'; + } + return 'updated their account information'; + } + + if (action === 'DELETE_USER') { + if (log.documentId && log.documentId !== log.userId) { + return 'deleted another user account'; + } + return 'deleted their account'; + } + + if (simpleActionDescriptions[action]) { + return simpleActionDescriptions[action]; + } + + return `performed action: ${action.toLowerCase().replace(/_/g, ' ')}`; +}; diff --git a/src/Routes.js b/src/Routes.js index 56ccf0e58..61aa37ab7 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: allowedIf.OFFICER_OR_ADMIN, + redirect: '/', + inAdminNavbar: true + }, ...memberRoutes, ];