diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index e943c5d28..c55aa1fa3 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -394,6 +394,7 @@ describe('PantriesController', () => { lastName: 'Johnson', email: 'alice.johnson@example.com', phone: '(617) 555-0100', + active: true, }, { userId: 11, @@ -401,6 +402,7 @@ describe('PantriesController', () => { lastName: 'Williams', email: 'bob.williams@example.com', phone: '(617) 555-0101', + active: false, }, ], }, diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index a5387fee4..c73969c0f 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -8,7 +8,6 @@ import { ConflictException, ForbiddenException, InternalServerErrorException, - Logger, NotFoundException, } from '@nestjs/common'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; @@ -943,10 +942,32 @@ describe('PantriesService', () => { expect(v.lastName).toBeDefined(); expect(v.email).toBeDefined(); expect(v.phone).toBeDefined(); + expect(typeof v.active).toBe('boolean'); }); }); }); + it('returns the active status for each volunteer', async () => { + await testDataSource.query( + `UPDATE users SET active = false WHERE email = 'james.t@volunteer.org'`, + ); + + const result = await service.getApprovedPantriesWithVolunteers(); + const volunteers = result.flatMap((p) => p.volunteers); + + const inactiveVolunteer = volunteers.find( + (v) => v.email === 'james.t@volunteer.org', + ); + expect(inactiveVolunteer).toBeDefined(); + expect(inactiveVolunteer?.active).toBe(false); + + const activeVolunteer = volunteers.find( + (v) => v.email === 'maria.g@volunteer.org', + ); + expect(activeVolunteer).toBeDefined(); + expect(activeVolunteer?.active).toBe(true); + }); + it('should return empty volunteers array when pantry has no volunteers', async () => { await service.addPantry({ contactFirstName: 'Test', diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index e8b42e2ea..36d341e83 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -531,6 +531,7 @@ export class PantriesService { lastName: volunteer.lastName, email: volunteer.email, phone: volunteer.phone, + active: volunteer.active, })), })); } diff --git a/apps/backend/src/pantries/types.ts b/apps/backend/src/pantries/types.ts index 543f6883c..e2f9d8d51 100644 --- a/apps/backend/src/pantries/types.ts +++ b/apps/backend/src/pantries/types.ts @@ -39,6 +39,7 @@ export interface AssignedVolunteer { lastName: string; email: string; phone: string; + active: boolean; } export enum RefrigeratedDonation { diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index ab4b95679..8d09e34a1 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -145,7 +145,7 @@ describe('VolunteersService', () => { }); describe('getVolunteersAndPantryAssignments', () => { - it('returns an empty array when there are no volunteers', async () => { + it('returns only admins when there are no volunteers', async () => { await testDataSource.query(`DELETE FROM allocations`); await testDataSource.query(`DELETE FROM orders`); await testDataSource.query( @@ -154,14 +154,59 @@ describe('VolunteersService', () => { const result = await service.getVolunteersAndPantryAssignments(); - expect(result).toEqual([]); + expect(result).toEqual([ + { + id: 1, + firstName: 'John', + lastName: 'Smith', + email: 'john.smith@ssf.org', + phone: '555-010-0101', + role: 'admin', + userCognitoSub: '', + active: true, + pantryIds: [], + }, + { + id: 2, + firstName: 'Sarah', + lastName: 'Johnson', + email: 'sarah.j@ssf.org', + phone: '555-010-0102', + role: 'admin', + userCognitoSub: '', + active: true, + pantryIds: [], + }, + ]); }); - it('returns all volunteers with their pantry assignments', async () => { + it('returns all volunteers and admins with their pantry assignments', async () => { const result = await service.getVolunteersAndPantryAssignments(); - expect(result.length).toEqual(4); + expect(result.length).toEqual(6); expect(result).toEqual([ + { + id: 1, + firstName: 'John', + lastName: 'Smith', + email: 'john.smith@ssf.org', + phone: '555-010-0101', + role: 'admin', + userCognitoSub: '', + active: true, + pantryIds: [], + }, + { + id: 2, + firstName: 'Sarah', + lastName: 'Johnson', + email: 'sarah.j@ssf.org', + phone: '555-010-0102', + role: 'admin', + userCognitoSub: '', + active: true, + pantryIds: [], + }, { id: 6, firstName: 'James', diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index a904eca69..8fb2f297e 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -41,6 +41,7 @@ export class VolunteersService { async getVolunteersAndPantryAssignments(): Promise { const volunteers = await this.usersService.findUsersByRoles([ Role.VOLUNTEER, + Role.ADMIN, ]); return volunteers.map((v) => { diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index dbc12d98c..9225a5a31 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -286,6 +286,14 @@ export class ApiClient { await this.axiosInstance.patch(`/api/users/${userId}/promote-volunteer`); } + public async deactivateUser(userId: number): Promise { + await this.axiosInstance.patch(`/api/users/${userId}/deactivate`); + } + + public async reactivateUser(userId: number): Promise { + await this.axiosInstance.patch(`/api/users/${userId}/reactivate`); + } + public async getFoodRequest(requestId: number): Promise { return this.axiosInstance .get(`/api/requests/${requestId}`) diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index 614df457a..c7137a69f 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -7,7 +7,7 @@ import ApplicationSubmitted from '@containers/applicationSubmitted'; import { submitPantryApplicationForm } from '@components/forms/pantryApplicationForm'; import ApprovePantries from '@containers/approvePantries'; import PantryApplicationDetails from '@containers/pantryApplicationDetails'; -import VolunteerManagement from '@containers/volunteerManagement'; +import VolunteerManagement from '@containers/userManagement'; import AdminDonation from '@containers/adminDonation'; import Homepage from '@containers/homepage'; import AdminOrderManagement from '@containers/adminOrderManagement'; diff --git a/apps/frontend/src/chakra-ui.d.ts b/apps/frontend/src/chakra-ui.d.ts index 98db62220..1a51c05d7 100644 --- a/apps/frontend/src/chakra-ui.d.ts +++ b/apps/frontend/src/chakra-ui.d.ts @@ -24,7 +24,7 @@ declare module '@chakra-ui/react' { // Menu components export interface MenuTriggerProps extends ComponentPropsStrictChildren {} - export interface MenuContentProps extends ComponentPropsStrictChildren {} + export interface MenuContentProps extends ComponentPropsLenientChildren {} export interface MenuItemProps extends ComponentPropsLenientChildren {} export interface MenuPositionerProps extends ComponentPropsStrictChildren {} export interface MenuRootProps extends ComponentPropsStrictChildren {} diff --git a/apps/frontend/src/components/Navbar.tsx b/apps/frontend/src/components/Navbar.tsx index dc3eb3101..8601ca700 100644 --- a/apps/frontend/src/components/Navbar.tsx +++ b/apps/frontend/src/components/Navbar.tsx @@ -29,10 +29,8 @@ const ROLE_NAV_SECTIONS: Record = { [Role.ADMIN]: [ { type: 'group', - label: 'Volunteers', - children: [ - { label: 'Volunteer Management', to: ROUTES.VOLUNTEER_MANAGEMENT }, - ], + label: 'Users', + children: [{ label: 'User Management', to: ROUTES.VOLUNTEER_MANAGEMENT }], }, { type: 'group', diff --git a/apps/frontend/src/components/forms/addNewVolunteerModal.tsx b/apps/frontend/src/components/forms/addNewVolunteerModal.tsx index e0dcafd34..1c0e3e96e 100644 --- a/apps/frontend/src/components/forms/addNewVolunteerModal.tsx +++ b/apps/frontend/src/components/forms/addNewVolunteerModal.tsx @@ -128,7 +128,7 @@ const NewVolunteerModal: React.FC = ({ fontFamily="Inter" color="#000" > - Add New Volunteer + Add New User setIsOpen(false)} @@ -140,7 +140,7 @@ const NewVolunteerModal: React.FC = ({ - Complete all information in the form to register a new volunteer. + Complete all information in the form to register a new user. diff --git a/apps/frontend/src/components/forms/assignVolunteersModal.tsx b/apps/frontend/src/components/forms/assignVolunteersModal.tsx index 5b1012fab..ea46af75f 100644 --- a/apps/frontend/src/components/forms/assignVolunteersModal.tsx +++ b/apps/frontend/src/components/forms/assignVolunteersModal.tsx @@ -17,6 +17,7 @@ import { AlertStatus, ApprovedPantryResponse, Assignments, + Role, } from '../../types/types'; import { SearchIcon } from 'lucide-react'; import { getInitials, USER_ICON_COLORS } from '@utils/utils'; @@ -65,11 +66,13 @@ const AssignVolunteersModal: React.FC = ({ const assignedIds = new Set(pantry.volunteers.map((v) => v.userId)); - const normalized: VolunteerDisplay[] = allVolunteers.map((v) => ({ - userId: v.id, - firstName: v.firstName, - lastName: v.lastName, - })); + const normalized: VolunteerDisplay[] = allVolunteers + .filter((v) => v.active) + .map((v) => ({ + userId: v.id, + firstName: v.firstName, + lastName: v.lastName, + })); setVolunteers(normalized); setSelectedIds(new Set(assignedIds)); diff --git a/apps/frontend/src/components/forms/confirmActionModal.tsx b/apps/frontend/src/components/forms/confirmActionModal.tsx new file mode 100644 index 000000000..2bebeb0d7 --- /dev/null +++ b/apps/frontend/src/components/forms/confirmActionModal.tsx @@ -0,0 +1,80 @@ +import { Dialog, Text, Button, CloseButton } from '@chakra-ui/react'; +import { useModalBodyCleanup } from '../../hooks/modalBodyCleanup'; + +interface ConfirmActionModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + volunteerName: string; + action: 'activate' | 'deactivate'; +} + +const ConfirmActionModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + volunteerName, + action, +}) => { + useModalBodyCleanup(); + + return ( + !e.open && onClose()} + > + + + + + + {action === 'activate' ? 'Activate user' : 'Deactivate user'} + + + + + + + + + Are you sure you want to {action} {volunteerName}? + + + + + + + + + + + ); +}; + +export default ConfirmActionModal; diff --git a/apps/frontend/src/containers/adminPantryManagement.tsx b/apps/frontend/src/containers/adminPantryManagement.tsx index ca9bcd7f6..27dc66112 100644 --- a/apps/frontend/src/containers/adminPantryManagement.tsx +++ b/apps/frontend/src/containers/adminPantryManagement.tsx @@ -313,7 +313,9 @@ const AdminPantryManagement: React.FC = () => { {pantry.volunteers && pantry.volunteers.length > 0 ? ( (() => { - const volunteers = pantry.volunteers; + const volunteers = pantry.volunteers.filter( + (volunteer) => volunteer.active, + ); const maxVisible = 3; const hasOverflow = volunteers.length > maxVisible; diff --git a/apps/frontend/src/containers/homepage.tsx b/apps/frontend/src/containers/homepage.tsx index fca3ab8e0..0e9e931c7 100644 --- a/apps/frontend/src/containers/homepage.tsx +++ b/apps/frontend/src/containers/homepage.tsx @@ -148,7 +148,7 @@ const Homepage: React.FC = () => { - Volunteer Management + User Management diff --git a/apps/frontend/src/containers/loginPage.tsx b/apps/frontend/src/containers/loginPage.tsx index cc08a821e..a92fa73d5 100644 --- a/apps/frontend/src/containers/loginPage.tsx +++ b/apps/frontend/src/containers/loginPage.tsx @@ -75,6 +75,13 @@ const LoginPage: React.FC = () => { navigate(from, { replace: true }); return; } + if (error.message === 'User is disabled.') { + setAlertMessage( + 'Your account has been deactivated.', + AlertStatus.ERROR, + ); + return; + } if ( error.name === 'NotAuthorizedException' || error.name === 'UserNotFoundException' diff --git a/apps/frontend/src/containers/volunteerManagement.tsx b/apps/frontend/src/containers/userManagement.tsx similarity index 68% rename from apps/frontend/src/containers/volunteerManagement.tsx rename to apps/frontend/src/containers/userManagement.tsx index e37d343fe..183ced43a 100644 --- a/apps/frontend/src/containers/volunteerManagement.tsx +++ b/apps/frontend/src/containers/userManagement.tsx @@ -8,6 +8,7 @@ import { Input, VStack, Box, + Badge, InputGroup, Pagination, ButtonGroup, @@ -22,10 +23,11 @@ import { ChevronLeft, EllipsisVertical, } from 'lucide-react'; -import { AlertStatus, User } from '../types/types'; +import { AlertStatus, Role, User } from '../types/types'; import ApiClient from '@api/apiClient'; import NewVolunteerModal from '@components/forms/addNewVolunteerModal'; import PromoteVolunteerModal from '@components/forms/promoteVolunteerModal'; +import ConfirmActionModal from '@components/forms/confirmActionModal'; import { FloatingAlert } from '@components/floatingAlert'; import { useAlert } from '../hooks/alert'; import { getInitials, USER_ICON_COLORS } from '@utils/utils'; @@ -37,6 +39,7 @@ const VolunteerManagement: React.FC = () => { const [searchName, setSearchName] = useState(''); const [selectedVolunteer, setSelectedVolunteer] = useState(null); const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false); + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [alertState, setAlertMessage] = useAlert(); @@ -77,10 +80,37 @@ const VolunteerManagement: React.FC = () => { } }; - const filteredVolunteers = volunteers.filter((a) => { - const fullName = `${a.firstName} ${a.lastName}`.toLowerCase(); - return fullName.includes(searchName.toLowerCase()); - }); + const handleToggleActive = async () => { + if (!selectedVolunteer) return; + + const wasActive = selectedVolunteer.active; + const fullName = `${selectedVolunteer.firstName} ${selectedVolunteer.lastName}`; + try { + if (wasActive) { + await ApiClient.deactivateUser(selectedVolunteer.id); + } else { + await ApiClient.reactivateUser(selectedVolunteer.id); + } + setAlertMessage( + `${fullName} has been ${wasActive ? 'deactivated' : 'activated'}.`, + AlertStatus.INFO, + ); + fetchVolunteers(); + } catch { + setAlertMessage( + `Failed to ${wasActive ? 'deactivate' : 'activate'} user.`, + AlertStatus.ERROR, + ); + } + }; + + const filteredVolunteers = volunteers + .filter((a) => { + const fullName = `${a.firstName} ${a.lastName}`.toLowerCase(); + return fullName.includes(searchName.toLowerCase()); + }) + // Deactivated users sort to the bottom of the list. + .sort((a, b) => Number(b.active) - Number(a.active)); const paginatedVolunteers = filteredVolunteers.slice( (currentPage - 1) * pageSize, @@ -96,7 +126,7 @@ const VolunteerManagement: React.FC = () => { return ( - Volunteer Management + User Management {alertState && ( { { - setAlertMessage('Volunteer added.', AlertStatus.INFO); + setAlertMessage('User added.', AlertStatus.INFO); }} onSubmitFail={() => { - setAlertMessage( - 'Volunteer could not be added.', - AlertStatus.ERROR, - ); + setAlertMessage('User could not be added.', AlertStatus.ERROR); }} /> @@ -160,7 +187,14 @@ const VolunteerManagement: React.FC = () => { textStyle="p2" fontWeight={600} > - Volunteer + Users + + + Status { { {volunteer.firstName} {volunteer.lastName} - {volunteer.email} - - + - navigate( - `${ROUTES.PANTRY_MANAGEMENT}?volunteerId=${volunteer.id}`, - ) - } + fontWeight={500} + fontSize="12px" + bg={volunteer.active ? 'teal.200' : 'neutral.300'} + color={volunteer.active ? 'teal.hover' : 'black'} > - View Assigned Pantries - + {volunteer.active ? 'Active' : 'Deactivated'} + + + {volunteer.email} + + {volunteer.role === Role.VOLUNTEER && ( + + navigate( + `${ROUTES.PANTRY_MANAGEMENT}?volunteerId=${volunteer.id}`, + ) + } + > + View Assigned Pantries + + )} @@ -235,14 +290,26 @@ const VolunteerManagement: React.FC = () => { + {volunteer.role === Role.VOLUNTEER && + volunteer.active && ( + { + setSelectedVolunteer(volunteer); + setIsPromoteModalOpen(true); + }} + > + Promote to Admin + + )} { setSelectedVolunteer(volunteer); - setIsPromoteModalOpen(true); + setIsConfirmModalOpen(true); }} > - Promote to Admin + {volunteer.active ? 'Deactivate' : 'Activate'} @@ -319,6 +386,19 @@ const VolunteerManagement: React.FC = () => { volunteerName={`${selectedVolunteer.firstName} ${selectedVolunteer.lastName}`} /> )} + + {selectedVolunteer && ( + { + setIsConfirmModalOpen(false); + setSelectedVolunteer(null); + }} + onConfirm={handleToggleActive} + volunteerName={`${selectedVolunteer.firstName} ${selectedVolunteer.lastName}`} + action={selectedVolunteer.active ? 'deactivate' : 'activate'} + /> + )} ); }; diff --git a/apps/frontend/src/types/types.ts b/apps/frontend/src/types/types.ts index 8f7195de6..b33389082 100644 --- a/apps/frontend/src/types/types.ts +++ b/apps/frontend/src/types/types.ts @@ -453,6 +453,7 @@ export interface AssignedVolunteer { lastName: string; email: string; phone: string; + active: boolean; } export interface CreateFoodRequestBody {