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}
+
+
+
+
+
+
+
+
+
+ Apply Filters
+
+
+ Clear Filters
+
+
+
+
+
+
+ {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}
+
+ )}
+
+
+
+
+ {formatTimestamp(log.createdAt)}
+
+
+ {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 (
+
+
Filter by Activity Type
+
setIsDropdownOpen(!isDropdownOpen)}
+ className='w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white text-left focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 flex justify-between items-center'
+ >
+ {activityFilters.length === 0 ? 'Select activities...' : `${activityFilters.length} selected`}
+
+
+
+
+
+ {isDropdownOpen && (
+
+
+ {activityTypes.map(activity => (
+
+ toggleActivityFilter(activity)}
+ className='form-checkbox h-4 w-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500'
+ />
+ {activity.replace(/_/g, ' ')}
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+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 (
+
+ Filter by First Name
+ 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 (
+
+ Filter by Last Name
+ 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
+
+
+
+
goToPage(currentPage - 1)}
+ disabled={currentPage === 1}
+ className={`px-3 py-2 rounded-md text-sm font-medium ${
+ currentPage === 1
+ ? 'bg-gray-700 text-gray-500 cursor-not-allowed'
+ : 'bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600'
+ }`}
+ >
+ Previous
+
+
+
+ {[...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 (
+ goToPage(pageNum)}
+ className={`px-3 py-2 rounded-md text-sm font-medium ${
+ currentPage === pageNum
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600'
+ }`}
+ >
+ {pageNum}
+
+ );
+ })}
+
+
+
goToPage(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ className={`px-3 py-2 rounded-md text-sm font-medium ${
+ currentPage === totalPages
+ ? 'bg-gray-700 text-gray-500 cursor-not-allowed'
+ : 'bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600'
+ }`}
+ >
+ Next
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+
+ Refresh Logs
+
+
+ );
+};
+
+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,
];