diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index ec741008..6a912bd0 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -12,6 +12,7 @@ import { RefreshTokenDto } from './dtos/refresh-token.dto'; import { ConfirmPasswordDto } from './dtos/confirm-password.dto'; import { ForgotPasswordDto } from './dtos/forgot-password.dto'; import { Role } from '../users/types'; +import { userSchemaDto } from '../users/dtos/userSchema.dto'; @Controller('auth') export class AuthController { @@ -29,13 +30,14 @@ export class AuthController { throw new BadRequestException(e.message); } - const user = await this.usersService.create( - signUpDto.email, - signUpDto.firstName, - signUpDto.lastName, - signUpDto.phone, - Role.VOLUNTEER, - ); + const createUserDto: userSchemaDto = { + email: signUpDto.email, + firstName: signUpDto.firstName, + lastName: signUpDto.lastName, + phone: signUpDto.phone, + role: Role.VOLUNTEER, + }; + const user = await this.usersService.create(createUserDto); return user; } diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 8c38f366..bc1feca6 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -1,4 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { + ConflictException, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; import { AdminDeleteUserCommand, AdminInitiateAuthCommand, @@ -7,6 +11,8 @@ import { ConfirmSignUpCommand, ForgotPasswordCommand, SignUpCommand, + AdminCreateUserCommand, + AdminGetUserCommand, } from '@aws-sdk/client-cognito-identity-provider'; import CognitoAuthConfig from './aws-exports'; @@ -73,6 +79,37 @@ export class AuthService { return response.UserConfirmed; } + async adminCreateUser({ + firstName, + lastName, + email, + }: Omit): Promise { + const createUserCommand = new AdminCreateUserCommand({ + UserPoolId: CognitoAuthConfig.userPoolId, + Username: email, + UserAttributes: [ + { Name: 'name', Value: `${firstName} ${lastName}` }, + { Name: 'email', Value: email }, + { Name: 'email_verified', Value: 'true' }, + ], + DesiredDeliveryMediums: ['EMAIL'], + }); + + try { + const response = await this.providerClient.send(createUserCommand); + const sub = response.User?.Attributes?.find( + (attr) => attr.Name === 'sub', + )?.Value; + return sub; + } catch (error) { + if (error.name == 'UsernameExistsException') { + throw new ConflictException('A user with this email already exists'); + } else { + throw new InternalServerErrorException('Failed to create user'); + } + } + } + async verifyUser(email: string, verificationCode: string): Promise { const confirmCommand = new ConfirmSignUpCommand({ ClientId: CognitoAuthConfig.userPoolClientId, diff --git a/apps/backend/src/foodManufacturers/manufacturers.module.ts b/apps/backend/src/foodManufacturers/manufacturers.module.ts index 2d9da5dc..2089f59f 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.module.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.module.ts @@ -1,11 +1,15 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FoodManufacturer } from './manufacturers.entity'; import { FoodManufacturersController } from './manufacturers.controller'; import { FoodManufacturersService } from './manufacturers.service'; +import { UsersModule } from '../users/users.module'; @Module({ - imports: [TypeOrmModule.forFeature([FoodManufacturer])], + imports: [ + TypeOrmModule.forFeature([FoodManufacturer]), + forwardRef(() => UsersModule), + ], controllers: [FoodManufacturersController], providers: [FoodManufacturersService], }) diff --git a/apps/backend/src/foodManufacturers/manufacturers.service.ts b/apps/backend/src/foodManufacturers/manufacturers.service.ts index 22cf3e3c..53076f46 100644 --- a/apps/backend/src/foodManufacturers/manufacturers.service.ts +++ b/apps/backend/src/foodManufacturers/manufacturers.service.ts @@ -7,12 +7,16 @@ import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application. import { User } from '../users/user.entity'; import { Role } from '../users/types'; import { ApplicationStatus } from '../shared/types'; +import { userSchemaDto } from '../users/dtos/userSchema.dto'; +import { UsersService } from '../users/users.service'; @Injectable() export class FoodManufacturersService { constructor( @InjectRepository(FoodManufacturer) private repo: Repository, + + private usersService: UsersService, ) {} async findOne(foodManufacturerId: number): Promise { @@ -99,7 +103,20 @@ export class FoodManufacturersService { throw new NotFoundException(`Food Manufacturer ${id} not found`); } - await this.repo.update(id, { status: ApplicationStatus.APPROVED }); + const createUserDto: userSchemaDto = { + email: foodManufacturer.foodManufacturerRepresentative.email, + firstName: foodManufacturer.foodManufacturerRepresentative.firstName, + lastName: foodManufacturer.foodManufacturerRepresentative.lastName, + phone: foodManufacturer.foodManufacturerRepresentative.phone, + role: Role.FOODMANUFACTURER, + }; + + const newFoodManufacturer = await this.usersService.create(createUserDto); + + await this.repo.update(id, { + status: ApplicationStatus.APPROVED, + foodManufacturerRepresentative: newFoodManufacturer, + }); } async deny(id: number) { diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index 7cd5ea9c..76a192fe 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -7,11 +7,13 @@ import { AuthModule } from '../auth/auth.module'; import { OrdersModule } from '../orders/order.module'; import { EmailsModule } from '../emails/email.module'; import { User } from '../users/user.entity'; +import { UsersModule } from '../users/users.module'; @Module({ imports: [ TypeOrmModule.forFeature([Pantry, User]), OrdersModule, + forwardRef(() => UsersModule), EmailsModule, forwardRef(() => AuthModule), ], diff --git a/apps/backend/src/pantries/pantries.service.spec.ts b/apps/backend/src/pantries/pantries.service.spec.ts index 1b54f4f3..ce163db7 100644 --- a/apps/backend/src/pantries/pantries.service.spec.ts +++ b/apps/backend/src/pantries/pantries.service.spec.ts @@ -15,8 +15,10 @@ import { AllergensConfidence, } from './types'; import { ApplicationStatus } from '../shared/types'; +import { UsersService } from '../users/users.service'; const mockRepository = mock>(); +const mockUsersService = mock(); describe('PantriesService', () => { let service: PantriesService; @@ -79,6 +81,10 @@ describe('PantriesService', () => { provide: getRepositoryToken(Pantry), useValue: mockRepository, }, + { + provide: UsersService, + useValue: mockUsersService, + }, ], }).compile(); @@ -162,20 +168,6 @@ describe('PantriesService', () => { // Approve pantry by ID (status = approved) describe('approve', () => { - it('should approve a pantry', async () => { - mockRepository.findOne.mockResolvedValueOnce(mockPendingPantry); - mockRepository.update.mockResolvedValueOnce(undefined); - - await service.approve(1); - - expect(mockRepository.findOne).toHaveBeenCalledWith({ - where: { pantryId: 1 }, - }); - expect(mockRepository.update).toHaveBeenCalledWith(1, { - status: 'approved', - }); - }); - it('should throw NotFoundException if pantry not found', async () => { mockRepository.findOne.mockResolvedValueOnce(null); diff --git a/apps/backend/src/pantries/pantries.service.ts b/apps/backend/src/pantries/pantries.service.ts index 24238e92..e27b7928 100644 --- a/apps/backend/src/pantries/pantries.service.ts +++ b/apps/backend/src/pantries/pantries.service.ts @@ -1,4 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + forwardRef, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { Pantry } from './pantries.entity'; @@ -7,10 +13,17 @@ import { validateId } from '../utils/validation.utils'; import { ApplicationStatus } from '../shared/types'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { Role } from '../users/types'; +import { userSchemaDto } from '../users/dtos/userSchema.dto'; +import { UsersService } from '../users/users.service'; @Injectable() export class PantriesService { - constructor(@InjectRepository(Pantry) private repo: Repository) {} + constructor( + @InjectRepository(Pantry) private repo: Repository, + + @Inject(forwardRef(() => UsersService)) + private usersService: UsersService, + ) {} async findOne(pantryId: number): Promise { validateId(pantryId, 'Pantry'); @@ -94,12 +107,28 @@ export class PantriesService { async approve(id: number) { validateId(id, 'Pantry'); - const pantry = await this.repo.findOne({ where: { pantryId: id } }); + const pantry = await this.repo.findOne({ + where: { pantryId: id }, + relations: ['pantryUser'], + }); if (!pantry) { throw new NotFoundException(`Pantry ${id} not found`); } - await this.repo.update(id, { status: ApplicationStatus.APPROVED }); + const createUserDto: userSchemaDto = { + email: pantry.pantryUser.email, + firstName: pantry.pantryUser.firstName, + lastName: pantry.pantryUser.lastName, + phone: pantry.pantryUser.phone, + role: Role.PANTRY, + }; + + const newPantryUser = await this.usersService.create(createUserDto); + + await this.repo.update(id, { + status: ApplicationStatus.APPROVED, + pantryUser: newPantryUser, + }); } async deny(id: number) { diff --git a/apps/backend/src/users/users.controller.spec.ts b/apps/backend/src/users/users.controller.spec.ts index 97811a0e..b29c7892 100644 --- a/apps/backend/src/users/users.controller.spec.ts +++ b/apps/backend/src/users/users.controller.spec.ts @@ -174,13 +174,7 @@ describe('UsersController', () => { const result = await controller.createUser(createUserSchema); expect(result).toEqual(createdUser); - expect(mockUserService.create).toHaveBeenCalledWith( - createUserSchema.email, - createUserSchema.firstName, - createUserSchema.lastName, - createUserSchema.phone, - createUserSchema.role, - ); + expect(mockUserService.create).toHaveBeenCalledWith(createUserSchema); }); it('should handle service errors', async () => { diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 7040fc37..338772f1 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -56,8 +56,7 @@ export class UsersController { @Post('/') async createUser(@Body() createUserDto: userSchemaDto): Promise { - const { email, firstName, lastName, phone, role } = createUserDto; - return this.usersService.create(email, firstName, lastName, phone, role); + return this.usersService.create(createUserDto); } @Post('/:id/pantries') diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 64145f5a..83a9be52 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -9,9 +9,12 @@ import { mock } from 'jest-mock-extended'; import { In } from 'typeorm'; import { BadRequestException } from '@nestjs/common'; import { PantriesService } from '../pantries/pantries.service'; +import { userSchemaDto } from './dtos/userSchema.dto'; +import { AuthService } from '../auth/auth.service'; const mockUserRepository = mock>(); const mockPantriesService = mock(); +const mockAuthService = mock(); const mockUser: Partial = { id: 1, @@ -32,6 +35,7 @@ describe('UsersService', () => { mockUserRepository.find.mockReset(); mockUserRepository.remove.mockReset(); mockPantriesService.findByIds.mockReset(); + mockAuthService.adminCreateUser.mockResolvedValue('mock-sub'); const module = await Test.createTestingModule({ providers: [ @@ -44,6 +48,10 @@ describe('UsersService', () => { provide: PantriesService, useValue: mockPantriesService, }, + { + provide: AuthService, + useValue: mockAuthService, + }, ], }).compile(); @@ -69,33 +77,32 @@ describe('UsersService', () => { describe('create', () => { it('should create a new user with auto-generated ID', async () => { - const userData = { + const createUserDto: userSchemaDto = { email: 'newuser@example.com', firstName: 'Jane', lastName: 'Smith', phone: '9876543210', role: Role.ADMIN, - } as User; + }; - const createdUser = { ...userData, id: 1 }; + const createdUser = { + ...createUserDto, + id: 1, + userCognitoSub: 'mock-sub', + } as User; mockUserRepository.create.mockReturnValue(createdUser); mockUserRepository.save.mockResolvedValue(createdUser); - const result = await service.create( - userData.email, - userData.firstName, - userData.lastName, - userData.phone, - userData.role, - ); + const result = await service.create(createUserDto); expect(result).toEqual(createdUser); expect(mockUserRepository.create).toHaveBeenCalledWith({ - role: userData.role, - firstName: userData.firstName, - lastName: userData.lastName, - email: userData.email, - phone: userData.phone, + role: createUserDto.role, + firstName: createUserDto.firstName, + lastName: createUserDto.lastName, + email: createUserDto.email, + phone: createUserDto.phone, + userCognitoSub: 'mock-sub', }); expect(mockUserRepository.save).toHaveBeenCalledWith(createdUser); }); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 8432ba1a..0d3912c6 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; @@ -10,6 +12,8 @@ import { Role } from './types'; import { validateId } from '../utils/validation.utils'; import { Pantry } from '../pantries/pantries.entity'; import { PantriesService } from '../pantries/pantries.service'; +import { AuthService } from '../auth/auth.service'; +import { userSchemaDto } from './dtos/userSchema.dto'; @Injectable() export class UsersService { @@ -17,24 +21,40 @@ export class UsersService { @InjectRepository(User) private repo: Repository, + @Inject(forwardRef(() => PantriesService)) private pantriesService: PantriesService, + + private authService: AuthService, ) {} - async create( - email: string, - firstName: string, - lastName: string, - phone: string, - role: Role, - ) { + async create(createUserDto: userSchemaDto): Promise { + const { email, firstName, lastName, phone, role } = createUserDto; + // Create first time user + const userCognitoSub = await this.authService.adminCreateUser({ + firstName, + lastName, + email, + phone, + }); + + // Pantry/Manufacturer users already exist, so just give them a userCognitoSub to login + if (role === Role.PANTRY || role === Role.FOODMANUFACTURER) { + const existingUser = await this.repo.findOneBy({ email }); + if (!existingUser) { + throw new NotFoundException(`User with email ${email} not found`); + } + existingUser.userCognitoSub = userCognitoSub; + return this.repo.save(existingUser); + } + const user = this.repo.create({ role, firstName, lastName, email, phone, + userCognitoSub, }); - return this.repo.save(user); } diff --git a/apps/frontend/src/containers/loginPage.tsx b/apps/frontend/src/containers/loginPage.tsx index 48f9793c..ab92891b 100644 --- a/apps/frontend/src/containers/loginPage.tsx +++ b/apps/frontend/src/containers/loginPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { signIn } from '@aws-amplify/auth'; +import { signIn, confirmSignIn } from '@aws-amplify/auth'; import { useNavigate, useLocation } from 'react-router-dom'; import { Box, @@ -15,10 +15,17 @@ import { import loginBackground from '../assets/login_background.png'; import { Eye, EyeOff } from 'lucide-react'; +type Step = 'login' | 'new-password'; + const LoginPage: React.FC = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmNewPassword, setConfirmNewPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmNewPassword, setShowConfirmNewPassword] = useState(false); + const [step, setStep] = useState('login'); const navigate = useNavigate(); const location = useLocation(); @@ -26,13 +33,35 @@ const LoginPage: React.FC = () => { const handleLogin = async () => { try { - await signIn({ username: email, password }); - navigate(from, { replace: true }); + const result = await signIn({ username: email, password }); + // On temporary password signin, this will trigger the need to create a new password + if ( + result.nextStep.signInStep === + 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED' + ) { + setStep('new-password'); + } else { + navigate(from, { replace: true }); + } } catch (error) { alert(error || 'Login failed'); } }; + // Sets the new password for the first time + const handleSetNewPassword = async () => { + if (newPassword !== confirmNewPassword) { + alert('Passwords need to match'); + return; + } + try { + await confirmSignIn({ challengeResponse: newPassword }); + navigate(from, { replace: true }); + } catch (error) { + alert('Failed to set new password: ' + error); + } + }; + const fieldHeaderStyles = { color: 'neutral.800', fontFamily: 'inter', @@ -66,85 +95,166 @@ const LoginPage: React.FC = () => { borderRadius="xl" boxShadow="xl" > - - - Log In - - Welcome to the Securing Safe Food (SSF) Portal. Please log in with - your account credentials. - - - - - Email - setEmail(e.target.value)} - /> - - - - Password - + {step === 'login' ? ( + + + Log In + + Welcome to the Securing Safe Food (SSF) Portal. Please log in + with your account credentials. + + + + + Email setPassword(e.target.value)} + onChange={(e) => setEmail(e.target.value)} /> - setShowPassword((prev) => !prev)} + + + + Password + + setPassword(e.target.value)} + /> + setShowPassword((prev) => !prev)} + > + + {showPassword && } + {!showPassword && } + + + + + + + + ) : ( + + + Set New Password + + Your account requires a new password before continuing. Your + password should be at least 8 characters. + + + + + New Password + + setNewPassword(e.target.value)} + /> + setShowNewPassword((prev) => !prev)} + > + + {showNewPassword && } + {!showNewPassword && } + + + + + + + Confirm Password + + setConfirmNewPassword(e.target.value)} + /> + setShowConfirmNewPassword((prev) => !prev)} + > + + {showConfirmNewPassword && } + {!showConfirmNewPassword && } + + + + + + + + )} + + {step === 'login' && ( + <> + + Don't have an account?{' '} + navigate('/signup')} + variant="underline" + textDecorationColor="neutral.300" > - - {showPassword && } - {!showPassword && } - - - - - - - - - Don’t have an account?{' '} - navigate('/signup')} - variant="underline" - textDecorationColor="neutral.300" - > - Sign up - - - - - navigate('/forgot-password')}> - Reset Password - - + Sign up + + + + + navigate('/forgot-password')}> + Reset Password + + + + )} );