Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/auth/ownership.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SetMetadata, Type } from '@nestjs/common';
import { Role } from '../users/types';

// Resolver function type to get the owner user ID for a given entity ID
// Should return the user IDs of the users who are authorized to call the
Expand All @@ -20,6 +21,7 @@ export interface ServiceRegistry {
export interface OwnershipConfig {
idParam: string;
resolver: OwnerIdResolver;
bypassRoles?: Role[];
}

export const OWNERSHIP_CHECK_KEY = 'ownership_check';
Expand Down
64 changes: 64 additions & 0 deletions apps/backend/src/auth/ownership.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,68 @@ describe('OwnershipGuard', () => {
// If it fails to parse, it will throw ForbiddenException before even calling the resolver.
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});

describe('OwnershipGuard bypassRoles', () => {
it('returns true when user role is in bypassRoles without calling resolver', async () => {
const resolver = jest.fn();
const config: OwnershipConfig = {
idParam: 'id',
resolver,
bypassRoles: [Role.VOLUNTEER],
};
const guard = new OwnershipGuard(makeReflector(config), makeModuleRef());
const ctx = makeExecutionContext(dummyUser, { id: '10' });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(resolver).not.toHaveBeenCalled();
});

it('does not bypass when user role is not in bypassRoles', async () => {
const pantryUser: User = { id: 55, role: Role.PANTRY } as User;
const config: OwnershipConfig = {
idParam: 'id',
resolver: async () => [99],
bypassRoles: [Role.VOLUNTEER],
};
const guard = new OwnershipGuard(makeReflector(config), makeModuleRef());
const ctx = makeExecutionContext(pantryUser, { id: '10' });
await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException);
});

it('bypasses when user role matches one of multiple bypassRoles', async () => {
const pantryUser: User = { id: 55, role: Role.PANTRY } as User;
const resolver = jest.fn();
const config: OwnershipConfig = {
idParam: 'id',
resolver,
bypassRoles: [Role.VOLUNTEER, Role.PANTRY],
};
const guard = new OwnershipGuard(makeReflector(config), makeModuleRef());
const ctx = makeExecutionContext(pantryUser, { id: '10' });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(resolver).not.toHaveBeenCalled();
});

it('admin bypasses regardless of bypassRoles', async () => {
const resolver = jest.fn();
const config: OwnershipConfig = {
idParam: 'id',
resolver,
bypassRoles: [],
};
const guard = new OwnershipGuard(makeReflector(config), makeModuleRef());
const ctx = makeExecutionContext(adminUser, { id: '5' });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
expect(resolver).not.toHaveBeenCalled();
});

it('proceeds to ownership check when bypassRoles is undefined', async () => {
const config: OwnershipConfig = {
idParam: 'id',
resolver: async () => [dummyUser.id],
};
const guard = new OwnershipGuard(makeReflector(config), makeModuleRef());
const ctx = makeExecutionContext(dummyUser, { id: '10' });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});
});
});
8 changes: 7 additions & 1 deletion apps/backend/src/auth/ownership.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ServiceRegistry,
} from './ownership.decorator';
import { User } from '../users/users.entity';
import { Role } from '../users/types';

@Injectable()
export class OwnershipGuard implements CanActivate {
Expand Down Expand Up @@ -43,6 +44,11 @@ export class OwnershipGuard implements CanActivate {
return true;
}

// Specified roles bypass ownership checks
if (config.bypassRoles?.includes(user.role as Role)) {
return true;
}

// Get the id from the parameters
const entityId = Number(req.params[config.idParam]);

Expand Down Expand Up @@ -87,7 +93,7 @@ export class OwnershipGuard implements CanActivate {
const service = moduleRef.get(serviceClass, { strict: false });
cache.set(serviceClass, service);
return service;
} catch (error) {
} catch {
throw new Error(`Could not resolve service: ${serviceClass.name}`);
}
},
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/orders/order.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export class OrdersController {
(pantry: Pantry) => [pantry.pantryUser.id],
);
},
bypassRoles: [Role.VOLUNTEER],
})
@Roles(Role.VOLUNTEER, Role.PANTRY)
@Get('/:orderId/request')
async getRequestFromOrder(
@Param('orderId', ParseIntPipe) orderId: number,
Expand Down
15 changes: 15 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
PantryWithUser,
Assignments,
UpdateProfileFields,
VolunteerOrder,
VolunteerAction,
} from 'types/types';

const defaultBaseUrl =
Expand Down Expand Up @@ -217,6 +219,19 @@ export class ApiClient {
return this.get(`/api/volunteers/${userId}/pantries`) as Promise<Pantry[]>;
}

public async getVolunteerOrders(userId: number): Promise<VolunteerOrder[]> {
return this.get(`/api/volunteers/${userId}/orders`) as Promise<
VolunteerOrder[]
>;
}

public async completeOrderAction(
orderId: number,
action: VolunteerAction,
): Promise<void> {
await this.patch(`/api/orders/${orderId}/complete-action`, { action });
}

public async updateUser(
userId: number,
fields: UpdateProfileFields,
Expand Down
9 changes: 9 additions & 0 deletions apps/frontend/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import ApproveFoodManufacturers from '@containers/approveFoodManufacturers';
import FoodManufacturerApplicationDetails from '@containers/foodManufacturerApplicationDetails';
import VolunteerRequestManagement from '@containers/volunteerRequestManagement';
import ProfilePage from '@containers/profilePage';
import VolunteerOrderManagement from '@containers/volunteerOrderManagement';

Amplify.configure(CognitoAuthConfig);

Expand Down Expand Up @@ -238,6 +239,14 @@ const router = createBrowserRouter([
</ProtectedRoute>
),
},
{
path: '/volunteer-order-management',
element: (
<ProtectedRoute>
<VolunteerOrderManagement />
</ProtectedRoute>
),
},
],
},
]);
Expand Down
181 changes: 181 additions & 0 deletions apps/frontend/src/components/forms/completeRequiredActionsModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React, { useState } from 'react';
import {
Box,
Button,
VStack,
CloseButton,
Text,
Flex,
Dialog,
} from '@chakra-ui/react';
import { CircleCheck } from 'lucide-react';
import ApiClient from '@api/apiClient';
import {
VolunteerOrder,
VolunteerAction,
VolunteerActionCompletion,
} from '../../types/types';
import { FloatingAlert } from '@components/floatingAlert';
import { useAlert } from '../../hooks/alert';

interface CompleteRequiredActionsModalProps {
order: VolunteerOrder;
isOpen: boolean;
onClose: () => void;
onActionCompleted: (orderId: number, action: VolunteerAction) => void;
}

const CompleteRequiredActionsModal: React.FC<
CompleteRequiredActionsModalProps
> = ({ order, isOpen, onClose, onActionCompleted }) => {
const [alertState, setAlertMessage] = useAlert();
const [loadingAction, setLoadingAction] = useState<VolunteerAction | null>(
null,
);

const completion: VolunteerActionCompletion = order.actionCompletion ?? {
confirmDonationReceipt: false,
notifyPantry: false,
};

const handleComplete = async (action: VolunteerAction) => {
setLoadingAction(action);
try {
await ApiClient.completeOrderAction(order.orderId, action);
onActionCompleted(order.orderId, action);
} catch {
setAlertMessage('Error completing action. Please try again.');
} finally {
setLoadingAction(null);
}
};

const sectionTitleStyles = {
fontWeight: '600',
fontSize: '14px',
color: 'neutral.800',
};

return (
<Dialog.Root
open={isOpen}
size="md"
onOpenChange={(e: { open: boolean }) => {
if (!e.open) onClose();
}}
closeOnInteractOutside
>
{alertState && (
<FloatingAlert
key={alertState.id}
message={alertState.message}
status="error"
timeout={6000}
/>
)}
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.CloseTrigger asChild>
<CloseButton size="lg" />
</Dialog.CloseTrigger>

<Dialog.Header pb={0}>
<Dialog.Title fontSize="lg" fontWeight={600}>
Complete Required Action
</Dialog.Title>
</Dialog.Header>
<Dialog.Body pb={6}>
<VStack align="stretch" gap={4}>
<Text fontSize="sm" color="gray.dark" mt={1}>
Please complete the following outstanding actions for this
order.
</Text>
<Box>
<Box
border="1px solid"
borderColor="neutral.100"
borderRadius="md"
p={4}
>
<Flex align="center" gap={2}>
{completion.confirmDonationReceipt && (
<CircleCheck size={18} />
)}
<Text {...sectionTitleStyles}>
Confirm Donation Receipt
</Text>
</Flex>
<Text textStyle="p2" color="gray.dark" mt={6}>
Please contact the food pantry to confirm their donation
receipt order.
</Text>
</Box>
{!completion.confirmDonationReceipt && (
<Flex justify="flex-end" mt={4}>
<Button
size="sm"
bg="neutral.900"
color="white"
fontSize="12px"
px={2}
h="20px"
_hover={{ bg: 'neutral.700' }}
loading={
loadingAction ===
VolunteerAction.CONFIRM_DONATION_RECEIPT
}
onClick={() =>
handleComplete(VolunteerAction.CONFIRM_DONATION_RECEIPT)
}
>
Mark as Complete
</Button>
</Flex>
)}
</Box>

<Box>
<Box
border="1px solid"
borderColor="neutral.100"
borderRadius="md"
p={4}
>
<Flex align="center" gap={2}>
{completion.notifyPantry && <CircleCheck size={18} />}
<Text {...sectionTitleStyles}>Notify Pantry</Text>
</Flex>
<Text textStyle="p2" color="gray.dark" mt={6}>
Subheading Content
</Text>
</Box>
{!completion.notifyPantry && (
<Flex justify="flex-end" mt={4}>
<Button
size="sm"
bg="neutral.900"
color="white"
fontSize="12px"
px={2}
h="20px"
_hover={{ bg: 'neutral.700' }}
loading={loadingAction === VolunteerAction.NOTIFY_PANTRY}
onClick={() =>
handleComplete(VolunteerAction.NOTIFY_PANTRY)
}
>
Mark as Complete
</Button>
</Flex>
)}
</Box>
</VStack>
</Dialog.Body>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
);
};

export default CompleteRequiredActionsModal;
6 changes: 4 additions & 2 deletions apps/frontend/src/containers/adminOrderManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -665,8 +665,10 @@ const OrderStatusSection: React.FC<OrderStatusSectionProps> = ({
color="white"
p={2}
>
{order.assignee.firstName.charAt(0).toUpperCase()}
{order.assignee.lastName.charAt(0).toUpperCase()}
{getInitials(
order.assignee.firstName,
order.assignee.lastName,
)}
</Box>
</Box>
</Table.Cell>
Expand Down
7 changes: 7 additions & 0 deletions apps/frontend/src/containers/homepage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ const Homepage: React.FC = () => {
</RouterLink>
</Link>
</ListItem>
<ListItem textAlign="center">
<Link asChild color="teal.500">
<RouterLink to="/volunteer-order-management">
Order Management
</RouterLink>
</Link>
</ListItem>
</List.Root>
</Box>

Expand Down
Loading
Loading