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
30 changes: 29 additions & 1 deletion apps/backend/src/pantries/pantries.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { User } from '../users/users.entity';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto';
import { RequestsService } from '../foodRequests/request.service';
import { Role } from '../users/types';
import { FoodRequest } from '../foodRequests/request.entity';

const mockPantriesService = mock<PantriesService>();
Expand Down Expand Up @@ -296,7 +297,7 @@ describe('PantriesController', () => {
});

describe('PATCH /:pantryId/application', () => {
const req = { user: { id: 1 } };
const req = { user: { id: 1, role: Role.PANTRY } };

it('should update a pantry application', async () => {
const pantryId = 1;
Expand All @@ -323,6 +324,7 @@ describe('PantriesController', () => {
pantryId,
mockUpdateData,
1,
Role.PANTRY,
);
});

Expand All @@ -346,6 +348,32 @@ describe('PantriesController', () => {
999,
mockUpdateData,
1,
Role.PANTRY,
);
});

it('should allow admin to update any pantry application', async () => {
const adminReq = { user: { id: 2, role: Role.ADMIN } };
const pantryId = 1;

const mockUpdateData: UpdatePantryApplicationDto = {
secondaryContactFirstName: 'Admin Updated',
};

mockPantriesService.updatePantryApplication.mockResolvedValue(mockPantry);

const result = await controller.updatePantryApplication(
adminReq as AuthenticatedRequest,
pantryId,
mockUpdateData,
);

expect(result).toEqual(mockPantry);
expect(mockPantriesService.updatePantryApplication).toHaveBeenCalledWith(
pantryId,
mockUpdateData,
2,
Role.ADMIN,
);
});
});
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/pantries/pantries.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ export class PantriesController {
idParam: 'pantryId',
resolver: resolvePantryAuthorizedUserIds,
})
@Roles(Role.PANTRY)
@Roles(Role.PANTRY, Role.ADMIN)
@Patch('/:pantryId/application')
async updatePantryApplication(
@Req() req: AuthenticatedRequest,
Expand All @@ -404,6 +404,7 @@ export class PantriesController {
pantryId,
pantryData,
req.user.id,
req.user.role,
);
}

Expand Down
36 changes: 35 additions & 1 deletion apps/backend/src/pantries/pantries.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
ConflictException,
ForbiddenException,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PantryApplicationDto } from './dtos/pantry-application.dto';
Expand All @@ -34,6 +33,7 @@ import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto
import { EmailsService } from '../emails/email.service';
import { mock } from 'jest-mock-extended';
import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates';
import { Role } from '../users/types';

jest.setTimeout(60000);

Expand Down Expand Up @@ -483,6 +483,40 @@ describe('PantriesService', () => {
),
);
});

it('allows admin to update any approved pantry (bypassing ownership check)', async () => {
const dto: UpdatePantryApplicationDto = {
secondaryContactFirstName: 'AdminUpdated',
itemsInStock: 'Admin updated items',
};

// User 1 is not the owner of pantry 1 (owner is user 10), but as admin should be able to edit
const adminUserId = 1;
const updatedPantry = await service.updatePantryApplication(
1,
dto,
adminUserId,
Role.ADMIN,
);

expect(updatedPantry.secondaryContactFirstName).toBe('AdminUpdated');
expect(updatedPantry.itemsInStock).toBe('Admin updated items');
});

it('still throws ForbiddenException for non-admin non-owner users', async () => {
const dto: UpdatePantryApplicationDto = {
itemsInStock: 'Should not work',
};

// User 999 is not the owner and not an admin
await expect(
service.updatePantryApplication(1, dto, 999, Role.PANTRY),
).rejects.toThrow(
new ForbiddenException(
'User 999 is not allowed to edit application for Pantry 1',
),
);
});
});

describe('getPantryStats (single pantry)', () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/src/pantries/pantries.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export class PantriesService {
pantryId: number,
pantryData: UpdatePantryApplicationDto,
currentUserId: number,
currentUserRole?: Role,
): Promise<Pantry> {
validateId(pantryId, 'Pantry');
validateId(currentUserId, 'User');
Expand All @@ -435,7 +436,10 @@ export class PantriesService {
throw new NotFoundException(`Pantry ${pantryId} not found`);
}

if (pantry.pantryUser.id !== currentUserId) {
if (
currentUserRole !== Role.ADMIN &&
pantry.pantryUser.id !== currentUserId
) {
throw new ForbiddenException(
`User ${currentUserId} is not allowed to edit application for Pantry ${pantryId}`,
);
Expand Down
44 changes: 33 additions & 11 deletions apps/frontend/src/components/forms/editablePantryApplication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,20 +254,33 @@ function validateRequired(form: FormState): boolean {
interface EditablePantryApplicationProps {
isEditing: boolean;
onEditingChange: (v: boolean) => void;
pantryId?: number;
initialApplication?: PantryWithUser;
onSaveSuccess?: (updatedApplication: PantryWithUser) => void;
}

const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
isEditing,
onEditingChange,
pantryId: propPantryId,
initialApplication,
onSaveSuccess,
}) => {
const [application, setApplication] = useState<PantryWithUser | null>(null);
const [application, setApplication] = useState<PantryWithUser | null>(
initialApplication ?? null,
);
const [alertState, setAlertMessage] = useAlert();
const [isSaving, setIsSaving] = useState(false);
const [form, setForm] = useState<FormState | null>(null);
const [isLoading, setIsLoading] = useState(!initialApplication);
const [form, setForm] = useState<FormState | null>(
initialApplication ? buildFormState(initialApplication) : null,
);

const fetchApplication = useCallback(async () => {
try {
const pantryId = await ApiClient.getCurrentUserPantryId();
setIsLoading(true);
const pantryId =
propPantryId ?? (await ApiClient.getCurrentUserPantryId());
if (pantryId) {
const data = await ApiClient.getPantry(pantryId);
setApplication(data);
Expand All @@ -278,12 +291,16 @@ const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
'Could not load application details. Please try again later.',
AlertStatus.ERROR,
);
} finally {
setIsLoading(false);
}
}, []);
}, [propPantryId]);

useEffect(() => {
fetchApplication();
}, [fetchApplication]);
if (!initialApplication) {
fetchApplication();
}
}, [fetchApplication, initialApplication]);

useEffect(() => {
if (!isEditing && application) setForm(buildFormState(application));
Expand Down Expand Up @@ -328,10 +345,10 @@ const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
}

const formData: UpdatePantryApplicationDto = {
secondaryContactFirstName: form.secondaryContactFirstName || undefined,
secondaryContactLastName: form.secondaryContactLastName || undefined,
secondaryContactEmail: form.secondaryContactEmail || undefined,
secondaryContactPhone: form.secondaryContactPhone || undefined,
secondaryContactFirstName: form.secondaryContactFirstName || null,
secondaryContactLastName: form.secondaryContactLastName || null,
secondaryContactEmail: form.secondaryContactEmail || null,
secondaryContactPhone: form.secondaryContactPhone || null,
shipmentAddressLine1: form.shipmentLine1 || undefined,
shipmentAddressLine2: form.shipmentLine2 || undefined,
shipmentAddressCity: form.shipmentCity || undefined,
Expand Down Expand Up @@ -370,7 +387,7 @@ const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
serveAllergicChildren:
(form.serveAllergicChildren as ServeAllergicChildren) || undefined,
activities: form.activities as Activity[],
activitiesComments: form.activitiesComments || undefined,
activitiesComments: form.activitiesComments || null,
itemsInStock: form.itemsInStock || undefined,
needMoreOptions: form.needMoreOptions || undefined,
};
Expand All @@ -385,6 +402,7 @@ const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
};
setApplication(updatedWithUser);
setForm(buildFormState(updatedWithUser));
onSaveSuccess?.(updatedWithUser);
onEditingChange(false);
} catch (err) {
if (axios.isAxiosError(err)) {
Expand Down Expand Up @@ -429,6 +447,10 @@ const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
}
};

if (isLoading) {
return null;
}

if (!application || !form) {
return (
<Center py={16}>
Expand Down
116 changes: 69 additions & 47 deletions apps/frontend/src/components/forms/profileAccountInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ import {
Tabs,
} from '@chakra-ui/react';
import { Pencil } from 'lucide-react';
import { Role, UpdateProfileFields, User } from '../../types/types';
import {
AlertStatus,
Role,
UpdateProfileFields,
User,
} from '../../types/types';
import { formatPhone } from '@utils/utils';
import EditablePantryApplication from '@components/forms/editablePantryApplication';
import EditableFMApplication from '@components/forms/editableFMApplication';
import { useAlert } from '../../hooks/alert';
import { FloatingAlert } from '@components/floatingAlert';

interface ProfileAccountInfoProps {
profile: User;
Expand Down Expand Up @@ -79,6 +86,7 @@ const ProfileAccountInfo: React.FC<ProfileAccountInfoProps> = ({
const [isEditingApplication, setIsEditingApplication] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [form, setForm] = useState({ firstName, lastName, phone });
const [alertState, setAlertMessage] = useAlert();

const isCurrentlyEditing =
activeTab === 'Account' ? isEditing : isEditingApplication;
Expand Down Expand Up @@ -197,56 +205,70 @@ const ProfileAccountInfo: React.FC<ProfileAccountInfoProps> = ({

if (showTabs) {
return (
<Tabs.Root
defaultValue="Account"
variant="line"
mx={2}
onValueChange={(e: { value: string }) => setActiveTab(e.value)}
>
<HStack justify="space-between" mb={8}>
<Tabs.List>
<Tabs.Trigger
value="Account"
color="neutral.800"
textStyle="p2"
borderBottom="0.5px solid"
borderColor="neutral.100"
_selected={{ borderColor: 'neutral.700' }}
>
Account
</Tabs.Trigger>
<Tabs.Trigger
value="Application"
color="neutral.800"
textStyle="p2"
borderBottom="0.5px solid"
borderColor="neutral.100"
_selected={{ borderColor: 'neutral.700' }}
>
Application
</Tabs.Trigger>
</Tabs.List>
{editButton}
</HStack>
<>
{alertState && (
<FloatingAlert
message={alertState.message}
status={alertState.status}
/>
)}
<Tabs.Root
defaultValue="Account"
variant="line"
mx={2}
onValueChange={(e: { value: string }) => setActiveTab(e.value)}
>
<HStack justify="space-between" mb={8}>
<Tabs.List>
<Tabs.Trigger
value="Account"
color="neutral.800"
textStyle="p2"
borderBottom="0.5px solid"
borderColor="neutral.100"
_selected={{ borderColor: 'neutral.700' }}
>
Account
</Tabs.Trigger>
<Tabs.Trigger
value="Application"
color="neutral.800"
textStyle="p2"
borderBottom="0.5px solid"
borderColor="neutral.100"
_selected={{ borderColor: 'neutral.700' }}
>
Application
</Tabs.Trigger>
</Tabs.List>
{editButton}
</HStack>

<Tabs.Content value="Account">{fields}</Tabs.Content>
<Tabs.Content value="Application">
{profile.role === Role.FOODMANUFACTURER ? (
foodManufacturerId != null && (
<EditableFMApplication
<Tabs.Content value="Account">{fields}</Tabs.Content>
<Tabs.Content value="Application">
{profile.role === Role.FOODMANUFACTURER ? (
foodManufacturerId != null && (
<EditableFMApplication
isEditing={isEditingApplication}
onEditingChange={setIsEditingApplication}
foodManufacturerId={foodManufacturerId}
/>
)
) : (
<EditablePantryApplication
isEditing={isEditingApplication}
onEditingChange={setIsEditingApplication}
foodManufacturerId={foodManufacturerId}
onSaveSuccess={() =>
setAlertMessage(
'Changes successfully saved.',
AlertStatus.INFO,
)
}
/>
)
) : (
<EditablePantryApplication
isEditing={isEditingApplication}
onEditingChange={setIsEditingApplication}
/>
)}
</Tabs.Content>
</Tabs.Root>
)}
</Tabs.Content>
</Tabs.Root>
</>
);
} else {
return (
Expand Down
Loading
Loading