Skip to content
2 changes: 1 addition & 1 deletion api/main_endpoints/models/AuditLog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
75 changes: 75 additions & 0 deletions api/main_endpoints/routes/AuditLog.js
Original file line number Diff line number Diff line change
@@ -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;
38 changes: 38 additions & 0 deletions src/APIFunctions/AuditLog.js
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions src/Components/Navbar/AdminNavbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ export default function UserNavBar(props) {
</svg>
)
},
{
title: 'Audit Logs',
route: '/audit-logs',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M5.616 20q-.667 0-1.141-.475T4 18.386V5.615q0-.666.475-1.14T5.615 4h4.7q-.136-.766.367-1.383Q11.184 2 12.01 2t1.328.617T13.685 4h4.7q.666 0 1.14.475T20 5.615v12.77q0 .666-.475 1.14t-1.14.475zM8 16.27h5q.213 0 .356-.145t.144-.356t-.144-.356t-.356-.144H8q-.213 0-.356.144q-.144.144-.144.357t.144.356t.356.143M8 12.5h8q.213 0 .356-.144t.144-.357t-.144-.356T16 11.5H8q-.213 0-.356.144t-.144.357t.144.356T8 12.5m0-3.77h8q.213 0 .356-.143q.144-.144.144-.357t-.144-.356T16 7.731H8q-.213 0-.356.144t-.144.357t.144.356T8 8.73m4-4.289q.325 0 .538-.212t.212-.538t-.213-.537T12 2.942t-.537.213t-.213.537t.213.538t.537.212"/>
</svg>
)
},
];

const renderRoutesForNavbar = (navbarLinks) => {
Expand Down
147 changes: 147 additions & 0 deletions src/Pages/AuditLog/AuditLog.js
Original file line number Diff line number Diff line change
@@ -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 <Loading />;
}

if (error) {
return <Error />;
}

return (
<div className='m-10'>
<div className='mb-8'>
<h1 className='text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white'>
Audit Logs
</h1>
<div className='mt-2 text-sm text-gray-500'>
Total logs: {totalLogs} | Showing: {currentLogs.length} | Page {currentPage} of {totalPages}
</div>

<div className='mt-6 p-4 bg-gray-800 rounded-lg border border-gray-700'>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4 items-end'>
<FirstNameFilter firstNameFilter={firstNameFilter} setFirstNameFilter={setFirstNameFilter} />
<LastNameFilter lastNameFilter={lastNameFilter} setLastNameFilter={setLastNameFilter} />
<FilterActivityTypes activityFilters={activityFilters} setActivityFilters={setActivityFilters} />
<div className='flex gap-2'>
<button
onClick={applyFilters}
className='px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500'
>
Apply Filters
</button>
<button
onClick={clearFilters}
className='px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-500'
>
Clear Filters
</button>
</div>
</div>
</div>
</div>

{currentLogs.length === 0 ? (
<div className='text-center py-16'>
<h3 className='text-lg font-medium text-white mb-2'>No audit logs found</h3>
<p className='text-gray-400'>
{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.'}
</p>
</div>
) : (
<div>
<div className='space-y-4'>
{currentLogs.map((log, index) => (
<AuditLogCard key={log._id || index} log={log} />
))}
</div>

{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
goToPage={goToPage}
itemsPerPage={itemsPerPage}
totalLogs={totalLogs}
/>
)}
</div>
)}

{currentLogs.length > 0 && <RefreshButton getAuditLogsFromDB={getAuditLogsFromDB} />}
</div>
);
}
53 changes: 53 additions & 0 deletions src/Pages/AuditLog/Components/AuditLogCard.js
Original file line number Diff line number Diff line change
@@ -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 (
<div
className='p-6 rounded-lg shadow-sm transition-all duration-200 hover:shadow-md bg-gray-800 border border-gray-700 hover:bg-gray-750'
>
<div className='flex items-start justify-between'>
<div className='flex-1'>
<div className='flex items-center space-x-3 mb-2'>
<div className='flex-shrink-0'>
<div className='w-2 h-2 bg-gray-400 rounded-full'></div>
</div>
<div className='flex-1'>
<p className='text-lg font-medium text-white'>
<span className='font-semibold text-blue-400'>
{log.userId ? `${log.userId.firstName} ${log.userId.lastName}` : 'Unknown User'}
</span>{' '}
<span className='text-gray-300'>{getActionDescription(log)}</span>
</p>

{log.documentId && log.documentId !== log.userId && (
<p className='text-sm text-gray-400 mt-1'>
Target: <span className='font-medium text-gray-300'>User {log.documentId}</span>
</p>
)}
</div>
</div>

<div className='ml-5 text-sm text-gray-400'>
<time dateTime={log.createdAt}>{formatTimestamp(log.createdAt)}</time>
</div>

{hasDetails(log) && <div className='ml-5 mt-4'>{formatDetails(log.details)}</div>}
</div>

<div className='flex-shrink-0 ml-4'>
<span className='inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-700 text-gray-300 border border-gray-600'>
{log.action.replace(/_/g, ' ')}
</span>
</div>
</div>
</div>
);
};

export default AuditLogCard;
15 changes: 15 additions & 0 deletions src/Pages/AuditLog/Components/Error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const Error = () => {
return (
<div className='m-10'>
<h1 className="text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white mb-8">
Audit Logs
</h1>
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<strong className="font-bold">Error: </strong>
<span className="block sm:inline">{error}</span>
</div>
</div>
);
};

export default Error;
Loading