diff --git a/api/main_endpoints/routes/OfficeAccessCard.js b/api/main_endpoints/routes/OfficeAccessCard.js index 53dabd032..cdd93e271 100644 --- a/api/main_endpoints/routes/OfficeAccessCard.js +++ b/api/main_endpoints/routes/OfficeAccessCard.js @@ -20,6 +20,7 @@ const { checkIfCardExists, generateAlias, deleteCard, + editAlias, } = require('../util/OfficeAccessCard.js'); const AuditLogActions = require('../util/auditLogActions.js'); const AuditLog = require('../models/AuditLog.js'); @@ -82,10 +83,10 @@ router.get('/verify', async (req, res) => { if (apiKey !== API_KEY) { writeLogToClient(req.method, { - statusCode: UNAUTHORIZED, + statusCode: FORBIDDEN, message: `Invalid API key: ${apiKey}`, }); - return res.sendStatus(UNAUTHORIZED); + return res.sendStatus(FORBIDDEN); } const cardExists = await checkIfCardExists({ cardBytes }); @@ -209,6 +210,56 @@ router.post('/getAllCards', async (req, res) => { } }); +router.post('/edit', async (req, res) => { + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); + } + + const { _id, alias } = req.body; + + const required = [ + { value: _id && /^[0-9a-fA-F]{24}$/.test(_id) ? _id : null, title: 'Valid, alphanumeric Card ID', }, + { value: alias?.trim(), title: 'New card alias', }, + ]; + + const missingValue = required.find(({ value }) => !value); + if (missingValue) { + writeLogToClient(req.method, { + statusCode: BAD_REQUEST, + message: `${missingValue.title} missing from request`, + }); + return res.status(BAD_REQUEST).send(`${missingValue.title} missing from request`); + } + + try { + const updatedCard = await editAlias(_id, alias); + + if (!updatedCard) { + return res.sendStatus(NOT_FOUND); + } + + // Log the edit action + AuditLog.create({ + userId: decoded.token._id, + action: AuditLogActions.EDIT_CARD, + details: { + newAlias: alias, + _id, + } + }); + + logger.info(`Card alias updated successfully for card ID: ${_id}`); + return res.status(OK).json({ + message: 'Card alias updated successfully', + card: updatedCard + }); + } catch (error) { + logger.error('Error updating card alias: ', error); + return res.status(SERVER_ERROR).send('Error updating card alias'); + } +}); + router.get('/listen', async (req, res) => { const decoded = await decodeToken(req, membershipState.OFFICER); if (decoded.status !== OK) { diff --git a/api/main_endpoints/util/OfficeAccessCard.js b/api/main_endpoints/util/OfficeAccessCard.js index c9b31cc2b..d24866564 100644 --- a/api/main_endpoints/util/OfficeAccessCard.js +++ b/api/main_endpoints/util/OfficeAccessCard.js @@ -20,8 +20,8 @@ function checkIfCardExists({ cardBytes = null, alias = null } = {}) { return resolve(false); } if (!result) { - const { description } = body; - logger.info(`Card:${description} not found in the database`); + const description = cardBytes !== null ? cardBytes : alias; + logger.info(`Card: ${description} not found in the database`); } return resolve(result); // return the document }); @@ -87,4 +87,30 @@ function deleteCard(alias) { }); } -module.exports = { checkIfCardExists, generateAlias, deleteCard }; +function editAlias(_id, newAlias) { + return new Promise((resolve) => { + try { + OfficeAccessCard.findByIdAndUpdate( + _id, + { $set: { alias: newAlias } }, + { new: true, useFindAndModify: false }, + (error, result) => { + if (error) { + logger.error('editAlias got an error querying mongodb: ', error); + return resolve(false); + } + if (!result) { + logger.info(`Card with id ${_id} not found in the database`); + return resolve(false); + } + return resolve(result); + } + ); + } catch (error) { + logger.error('editAlias caught an error: ', error); + return resolve(false); + } + }); +} + +module.exports = { checkIfCardExists, generateAlias, deleteCard, editAlias }; diff --git a/api/main_endpoints/util/auditLogActions.js b/api/main_endpoints/util/auditLogActions.js index b1da29785..13e7a3d5b 100644 --- a/api/main_endpoints/util/auditLogActions.js +++ b/api/main_endpoints/util/auditLogActions.js @@ -14,6 +14,7 @@ const AuditLogActions = { VERIFY_CARD: 'VERIFY_CARD', ADD_CARD: 'ADD_CARD', DELETE_CARD: 'DELETE_CARD', + EDIT_CARD: 'EDIT_CARD', }; module.exports = AuditLogActions; diff --git a/src/APIFunctions/CardReader.js b/src/APIFunctions/CardReader.js index 25c1e4a4b..1f7fdfcee 100644 --- a/src/APIFunctions/CardReader.js +++ b/src/APIFunctions/CardReader.js @@ -55,3 +55,28 @@ export async function deleteCardFromDb(token, alias) { } return status; } + +export async function editCardAlias(token, _id, alias) { + let status = new ApiResponse(); + try { + const url = new URL('/api/OfficeAccessCard/edit', BASE_API_URL); + const res = await fetch(url.href, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ _id, alias }), + }); + if (res.ok) { + const result = await res.json(); + status.responseData = result; + } else { + status.error = true; + } + } catch (err) { + status.error = true; + status.responseData = err; + } + return status; +} diff --git a/src/Pages/CardReader/CardReader.js b/src/Pages/CardReader/CardReader.js index d068ffc72..5bb60e9bc 100644 --- a/src/Pages/CardReader/CardReader.js +++ b/src/Pages/CardReader/CardReader.js @@ -1,9 +1,9 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { BASE_API_URL } from '../../Enums'; import { useSCE } from '../../Components/context/SceContext'; -import { getAllCardsFromDb, deleteCardFromDb } from '../../APIFunctions/CardReader'; +import { getAllCardsFromDb, deleteCardFromDb, editCardAlias } from '../../APIFunctions/CardReader'; import ConfirmationModal from '../../Components/DecisionModal/ConfirmationModal'; -import { trashcanSymbol } from '../Overview/SVG'; +import { trashcanSymbol, pencilSymbol, checkSymbol, cancelSymbol } from '../Overview/SVG'; const header = [ 'Time'.padEnd(29), @@ -17,10 +17,13 @@ const header = [ export default function CardReader() { const { user } = useSCE(); const token = user.token; + const [logs, setLogs] = useState([]); const [cards, setCards] = useState([]); const [toggleDelete, setToggleDelete] = useState(false); const [cardToDelete, setCardToDelete] = useState({}); + const [editingCardId, setEditingCardId] = useState(null); + const [editedAlias, setEditedAlias] = useState(''); const [tab, setTab] = useState(() => { const params = new URLSearchParams(window.location.search); return params.get('tab') || 'registry'; @@ -44,9 +47,9 @@ export default function CardReader() { const getColumnClassName = (columnName) => { let className = 'px-6 py-3 whitespace-nowrap '; - if(columnName === 'lastVerifiedAt' | columnName === 'registrationDate'){ + if (['lastVerifiedAt', 'registrationDate'].includes(columnName)) { className += 'hidden md:table-cell '; - } else if (columnName === 'verifiedCount'){ + } else if (columnName === 'verifiedCount') { className += 'hidden lg:table-cell'; } return className; @@ -89,12 +92,78 @@ export default function CardReader() { setCardToDelete(card); } + function handleEditClick(card) { + if (editingCardId === card._id) { + setEditingCardId(null); + setEditedAlias(''); + } else { + setEditingCardId(card._id); + setEditedAlias(card.alias); + } + } + + async function handleSaveEdit(cardId) { + if (!editedAlias.trim()) { + return; // Don't save empty alias + } + + try { + const response = await editCardAlias(token, cardId, editedAlias.trim()); + if (!response.error) { + // Refetch all cards from database to ensure UI matches server reality + await getAllCards(); + setEditingCardId(null); + setEditedAlias(''); + } + } catch (error) { + setLogs( + (currLogs) => [ + '[error] unable to update card alias, check browser logs: \n' + error, + ...currLogs, + ] + ); + } + } + + function handleEditKeyDown(key) { + if (key === 'Enter') { + handleSaveEdit(editingCardId); + } else if (key === 'Escape') { + setEditingCardId(null); + setEditedAlias(''); + } + } + + function renderInputOrAlias(card) { + if (editingCardId === card._id) { + return ( + setEditedAlias(e.target.value)} + onKeyDown={(e) => handleEditKeyDown(e.key)} + className='bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-white font-medium m-0 px-1 py-0 focus:outline-none focus:ring-1 focus:ring-blue-500' + style={{ width: '16ch' }} + autoFocus + /> + ); + } + return ( +
+ + {card.alias} + +
+ ); + } + function CardEntry({ card }) { + const isEditing = editingCardId === card._id; return (
- {card.alias} + {renderInputOrAlias(card)}
@@ -113,12 +182,33 @@ export default function CardReader() { - +
+ + + +
); diff --git a/src/Pages/Overview/SVG.js b/src/Pages/Overview/SVG.js index 9584f5007..2b81297c9 100644 --- a/src/Pages/Overview/SVG.js +++ b/src/Pages/Overview/SVG.js @@ -51,6 +51,32 @@ export function trashcanSymbol(color = 'black') { ); } +export function pencilSymbol(color = '#6b7280') { + return ( + + + + + ); +} + +export function checkSymbol(color = '#22c55e') { + return ( + + + + ); +} + +export function cancelSymbol(color = '#ef4444') { + return ( + + + + + ); +} + export function copyIcon(className, onClick) { return ( diff --git a/test/api/OfficeAccessCard.js b/test/api/OfficeAccessCard.js index 74145b3d7..a3eabc9af 100644 --- a/test/api/OfficeAccessCard.js +++ b/test/api/OfficeAccessCard.js @@ -20,6 +20,7 @@ const { SERVER_ERROR, FORBIDDEN, } = require('../../api/util/constants').STATUS_CODES; +const { MEMBERSHIP_STATE } = require('../../api/util/constants'); const { initializeTokenMock, setTokenStatus, @@ -45,6 +46,8 @@ const token = ''; describe('OfficeAccessCard', () => { let deleteCardStub = null; let getAllCardsStub = null; + let editAliasStub = null; + let testCardId = null; const VALID_CARD_BYTES = 'wesleys card'; const NEW_CARD_BYTES = 'dials card'; @@ -52,10 +55,14 @@ describe('OfficeAccessCard', () => { const VALID_ALIAS = 'gauravs card'; const INVALID_ALIAS = 'bobs card'; + const NEW_ALIAS = 'updated test card'; + const EMPTY_ALIAS = ''; + const WHITESPACE_ALIAS = ' '; const VERIFY_API_PATH = '/api/OfficeAccessCard/verify'; const DELETE_API_PATH = '/api/OfficeAccessCard/delete'; const GET_ALL_CARDS_API_PATH = '/api/OfficeAccessCard/getAllCards'; + const EDIT_API_PATH = '/api/OfficeAccessCard/edit'; const INCREMENT_VERIFY_COUNT = 0; before(() => { @@ -76,7 +83,10 @@ describe('OfficeAccessCard', () => { }); return new Promise((resolve, reject) => { testOfficeAccessCard.save() - .then(resolve) + .then(savedCard => { + testCardId = savedCard._id.toString(); + resolve(savedCard); + }) .catch(reject); }); }); @@ -116,14 +126,14 @@ describe('OfficeAccessCard', () => { expect(result).to.have.status(BAD_REQUEST); }); - it('Should return 401 with invalid api key', async () => { + it('Should return 403 with invalid api key', async () => { const params = new URLSearchParams(); params.append('cardBytes', VALID_CARD_BYTES); const path = VERIFY_API_PATH + '?' + params.toString(); const invalidApiKey = API_KEY + '-invalid-suffix'; const result = await test.sendGetRequestWithApiKey( invalidApiKey + '', path); - expect(result).to.have.status(UNAUTHORIZED); + expect(result).to.have.status(FORBIDDEN); }); it('Should return 404 with valid api key and unknown card', async () => { @@ -241,4 +251,94 @@ describe('OfficeAccessCard', () => { }); }); + describe('POST edit', () => { + it('Should return 401 when token is not sent', async () => { + const result = await test.sendPostRequest(EDIT_API_PATH); + expect(result).to.have.status(UNAUTHORIZED); + }); + + it('Should return 401 when invalid token is sent', async () => { + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH); + expect(result).to.have.status(UNAUTHORIZED); + }); + + it('Should return 400 when _id is missing from request body', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { alias: NEW_ALIAS }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should return 400 when alias is missing from request body', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should return 404 when trying to edit a non-existent card', async () => { + setTokenStatus(true); + const nonExistentId = new mongoose.Types.ObjectId().toString(); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: nonExistentId, alias: NEW_ALIAS }); + expect(result).to.have.status(NOT_FOUND); + }); + + it('Should return 400 when _id is not a valid ObjectId', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: 'invalid-id', alias: NEW_ALIAS }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should return 200 and successfully update alias for valid request', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: NEW_ALIAS }); + expect(result).to.have.status(OK); + expect(result.body).to.have.property('message', 'Card alias updated successfully'); + expect(result.body).to.have.property('card'); + expect(result.body.card).to.have.property('alias', NEW_ALIAS); + }); + + it('Should actually update the alias in the database', async () => { + setTokenStatus(true); + await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: NEW_ALIAS }); + + const updatedCard = await OfficeAccessCard.findById(testCardId); + expect(updatedCard.alias).to.equal(NEW_ALIAS); + }); + + it('Should handle empty alias by returning 400', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: EMPTY_ALIAS }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should handle whitespace-only alias by returning 400', async () => { + setTokenStatus(true); + const result = await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: WHITESPACE_ALIAS }); + expect(result).to.have.status(BAD_REQUEST); + }); + + it('Should preserve other card properties when updating alias', async () => { + setTokenStatus(true); + const originalCard = await OfficeAccessCard.findById(testCardId); + const originalCardBytes = originalCard.cardBytes; + const originalVerifiedCount = originalCard.verifiedCount; + + await test.sendPostRequestWithToken(token, + EDIT_API_PATH, { _id: testCardId, alias: NEW_ALIAS }); + + const updatedCard = await OfficeAccessCard.findById(testCardId); + expect(updatedCard.cardBytes).to.equal(originalCardBytes); + expect(updatedCard.verifiedCount).to.equal(originalVerifiedCount); + expect(updatedCard.alias).to.equal(NEW_ALIAS); + }); + }); + });