diff --git a/.env.example b/.env.example index c0ee816e..1c2d9c96 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,11 @@ DATABASE_HOST=localhost DATABASE_PORT=5432 DATABASE_NAME=securing-safe-food DATABASE_USERNAME=postgres -DATABASE_PASSWORD=PLACEHOLDER_PASSWORD \ No newline at end of file +DATABASE_PASSWORD=PLACEHOLDER_PASSWORD + +AWS_ACCESS_KEY_ID = PLACEHOLDER_AWS_ACCESS_KEY +AWS_SECRET_ACCESS_KEY = PLACEHOLDER_AWS_SECRET_KEY +AWS_REGION = PLACEHOLDER_AWS_REGION +COGNITO_CLIENT_SECRET = PLACEHOLDER_COGNITO_CLIENT_SECRET + +AWS_BUCKET_NAME = 'confirm-delivery-photos' \ No newline at end of file diff --git a/apps/backend/README.md b/apps/backend/README.md index 8e4e513c..9cb8488f 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -25,4 +25,43 @@ You can check that your database connection details are correct by running `nx s "LOG 🚀 Application is running on: http://localhost:3000/api" ``` -Finally, run `yarn run typeorm:migrate` to load all the tables into your database. If everything is set up correctly, you should see "Migration ... has been executed successfully." in the terminal. \ No newline at end of file +Finally, run `yarn run typeorm:migrate` to load all the tables into your database. If everything is set up correctly, you should see "Migration ... has been executed successfully." in the terminal. + +# AWS Setup + +We have a few environment variables that we utilize to access several AWS services throughout the application. Below is a list of each of them and how to access each after logging in to AWS + +1. `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`: + - Click on your username in the top right corner, and navigate to Security Credentials + - Scroll down to access keys, and create a new key + - Select "Local code" as the purpose for the key, and add an optional description + - Replace both the public and secret keys in the .env file to those values. Note that the secret key will not be accessible after you leave this page + - Click done + +2. `AWS_REGION`: +This can be found next to your profile name when you login to the main page. Some accounts may be different, but we generally use us-east-1 or us-east-2. +This is the region that you find on the right side after clicking on the location dropdown, usually saying "United States (*some region*)". +For example, if we want to use Ohio as the region, we would put `AWS_REGION="us-east2"` + +3. `AWS_BUCKET_NAME`: +This one is already given to you. As of right now, we only use one bucket, confirm-delivery-photos to store photos in a public S3 Bucket. This may be subject to change as we use S3 more in the project. + +4. `COGNITO_CLIENT_SECRET`: +This is used to help authenticate you with AWS Cognito and allow you to properly sign in using proper credential. To find this: + - Navigate to AWS Cognito + - Make sure you are on "United States (N. Virginia) as your region + - Go into User pools and click on the one that says "ssf" (NOTE: You can also validate the User pool id in the `auth/aws_exports.ts` file) + - Go to App Clients, and click on 'ssf client w secret' + - There, you can validate the information in `auth/aws_exports.ts` (the `userPoolClientId`), as well as copy the client secret into your env file + +5. Creating a new user within AWS Cognito + There are 2 ways you can create a new user in AWS Cognito. The simplest, is through loading the up, going to the landing page, and creating a new account there. If you choose to do it alternatively through the console, follow these steps: + - Navigate to AWS Cognito + - Make sure you are on "United States (N. Virginia) as your region + - Go into User pools and click on the one that says "ssf" + - Go to Users + - If you do not already see your email there, create a new User, setting an email in password (this will be what you login with on the frontend) + - Click 'Create User' + - Load up the app, and go to the landing page + - Verify you are able to login with these new credentials you created + \ No newline at end of file diff --git a/apps/backend/src/allocations/allocations.module.ts b/apps/backend/src/allocations/allocations.module.ts index fed7360b..3284e1af 100644 --- a/apps/backend/src/allocations/allocations.module.ts +++ b/apps/backend/src/allocations/allocations.module.ts @@ -1,15 +1,17 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Allocation } from './allocations.entity'; import { AllocationsController } from './allocations.controller'; import { AllocationsService } from './allocations.service'; -import { AuthService } from '../auth/auth.service'; -import { JwtStrategy } from '../auth/jwt.strategy'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Allocation])], + imports: [ + TypeOrmModule.forFeature([Allocation]), + forwardRef(() => AuthModule), + ], controllers: [AllocationsController], - providers: [AllocationsService, AuthService, JwtStrategy], + providers: [AllocationsService], exports: [AllocationsService], }) export class AllocationModule {} diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index eac8a5b6..3af03db0 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -1,14 +1,17 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { PassportModule } from '@nestjs/passport'; - import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; import { UsersModule } from '../users/users.module'; @Module({ - imports: [UsersModule, PassportModule.register({ defaultStrategy: 'jwt' })], + imports: [ + forwardRef(() => UsersModule), + PassportModule.register({ defaultStrategy: 'jwt' }), + ], controllers: [AuthController], providers: [AuthService, JwtStrategy], + exports: [AuthService, JwtStrategy], }) export class AuthModule {} diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index a0bae3ad..5ebb427e 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { AdminDeleteUserCommand, AdminInitiateAuthCommand, - AttributeType, CognitoIdentityProviderClient, ConfirmForgotPasswordCommand, ConfirmSignUpCommand, @@ -29,8 +28,8 @@ export class AuthService { this.providerClient = new CognitoIdentityProviderClient({ region: CognitoAuthConfig.region, credentials: { - accessKeyId: process.env.NX_AWS_ACCESS_KEY, - secretAccessKey: process.env.NX_AWS_SECRET_ACCESS_KEY, + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, }, }); @@ -43,28 +42,17 @@ export class AuthService { // (see https://docs.aws.amazon.com/cognito/latest/developerguide/signing-up-users-in-your-app.html#cognito-user-pools-computing-secret-hash) calculateHash(username: string): string { const hmac = createHmac('sha256', this.clientSecret); - hmac.update(username + CognitoAuthConfig.clientId); + hmac.update(username + CognitoAuthConfig.userPoolClientId); return hmac.digest('base64'); } - async getUser(userSub: string): Promise { - const listUsersCommand = new ListUsersCommand({ - UserPoolId: CognitoAuthConfig.userPoolId, - Filter: `sub = "${userSub}"`, - }); - - // TODO need error handling - const { Users } = await this.providerClient.send(listUsersCommand); - return Users[0].Attributes; - } - async signup( { firstName, lastName, email, password }: SignUpDto, role: Role = Role.VOLUNTEER, ): Promise { // Needs error handling const signUpCommand = new SignUpCommand({ - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, SecretHash: this.calculateHash(email), Username: email, Password: password, @@ -88,7 +76,7 @@ export class AuthService { async verifyUser(email: string, verificationCode: string): Promise { const confirmCommand = new ConfirmSignUpCommand({ - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, SecretHash: this.calculateHash(email), Username: email, ConfirmationCode: verificationCode, @@ -100,7 +88,7 @@ export class AuthService { async signin({ email, password }: SignInDto): Promise { const signInCommand = new AdminInitiateAuthCommand({ AuthFlow: 'ADMIN_USER_PASSWORD_AUTH', - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, UserPoolId: CognitoAuthConfig.userPoolId, AuthParameters: { USERNAME: email, @@ -125,7 +113,7 @@ export class AuthService { }: RefreshTokenDto): Promise { const refreshCommand = new AdminInitiateAuthCommand({ AuthFlow: 'REFRESH_TOKEN_AUTH', - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, UserPoolId: CognitoAuthConfig.userPoolId, AuthParameters: { REFRESH_TOKEN: refreshToken, @@ -144,7 +132,7 @@ export class AuthService { async forgotPassword(email: string) { const forgotCommand = new ForgotPasswordCommand({ - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, Username: email, SecretHash: this.calculateHash(email), }); @@ -158,7 +146,7 @@ export class AuthService { newPassword, }: ConfirmPasswordDto) { const confirmComamnd = new ConfirmForgotPasswordCommand({ - ClientId: CognitoAuthConfig.clientId, + ClientId: CognitoAuthConfig.userPoolClientId, SecretHash: this.calculateHash(email), Username: email, ConfirmationCode: confirmationCode, diff --git a/apps/backend/src/auth/aws-exports.ts b/apps/backend/src/auth/aws-exports.ts index 48541a19..97711cea 100644 --- a/apps/backend/src/auth/aws-exports.ts +++ b/apps/backend/src/auth/aws-exports.ts @@ -1,6 +1,6 @@ const CognitoAuthConfig = { - userPoolId: 'us-east-1_oshVQXLX6', - clientId: '42bfm2o2pmk57mpm5399s0e9no', + userPoolClientId: '1kehn2mr64h94mire6os55bib7', + userPoolId: 'us-east-1_StSYXMibq', region: 'us-east-1', }; diff --git a/apps/backend/src/auth/jwt.strategy.ts b/apps/backend/src/auth/jwt.strategy.ts index 44d8789d..8e040e81 100644 --- a/apps/backend/src/auth/jwt.strategy.ts +++ b/apps/backend/src/auth/jwt.strategy.ts @@ -1,19 +1,22 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { passportJwtSecret } from 'jwks-rsa'; import { ExtractJwt, Strategy } from 'passport-jwt'; - +import { UsersService } from '../users/users.service'; import CognitoAuthConfig from './aws-exports'; +import { AuthService } from './auth.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor() { + constructor( + private usersService: UsersService, + ) { const cognitoAuthority = `https://cognito-idp.${CognitoAuthConfig.region}.amazonaws.com/${CognitoAuthConfig.userPoolId}`; super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - _audience: CognitoAuthConfig.clientId, + _audience: CognitoAuthConfig.userPoolClientId, issuer: cognitoAuthority, algorithms: ['RS256'], secretOrKeyProvider: passportJwtSecret({ @@ -26,6 +29,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload) { - return { idUser: payload.sub, email: payload.email }; + const dbUser = await this.usersService.findUserByCognitoId(payload.sub); + console.log('Database user retrieved:', dbUser); + return dbUser; } } diff --git a/apps/backend/src/auth/roles.decorator.ts b/apps/backend/src/auth/roles.decorator.ts new file mode 100644 index 00000000..a28701d2 --- /dev/null +++ b/apps/backend/src/auth/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { Role } from '../users/types'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/backend/src/auth/roles.guard.ts b/apps/backend/src/auth/roles.guard.ts new file mode 100644 index 00000000..b2e71324 --- /dev/null +++ b/apps/backend/src/auth/roles.guard.ts @@ -0,0 +1,24 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Role } from '../users/types'; +import { ROLES_KEY } from './roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + + return requiredRoles.some((role) => user.role === role); + } +} diff --git a/apps/backend/src/config/typeorm.ts b/apps/backend/src/config/typeorm.ts index 82384673..d1de2d1f 100644 --- a/apps/backend/src/config/typeorm.ts +++ b/apps/backend/src/config/typeorm.ts @@ -27,6 +27,7 @@ import { RemoveMultipleVolunteerTypes1764811878152 } from '../migrations/1764811 import { RemoveUnusedStatuses1764816885341 } from '../migrations/1764816885341-RemoveUnusedStatuses'; import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-UpdatePantryFields'; import { PopulateDummyData1768501812134 } from '../migrations/1768501812134-populateDummyData'; +import { AddUserPoolId1769189327767 } from '../migrations/1769189327767-AddUserPoolId'; const config = { type: 'postgres', @@ -46,8 +47,8 @@ const config = { ReviseTables1737522923066, UpdateUserRole1737816745912, UpdatePantriesTable1737906317154, - UpdateDonations1738697216020, UpdateDonationColTypes1741708808976, + UpdateDonations1738697216020, UpdatePantriesTable1738172265266, UpdatePantriesTable1739056029076, AssignmentsPantryIdNotUnique1758384669652, @@ -67,6 +68,7 @@ const config = { RemoveMultipleVolunteerTypes1764811878152, RemoveUnusedStatuses1764816885341, PopulateDummyData1768501812134, + AddUserPoolId1769189327767, ], }; diff --git a/apps/backend/src/donationItems/donationItems.controller.ts b/apps/backend/src/donationItems/donationItems.controller.ts index 191b53fb..56e83278 100644 --- a/apps/backend/src/donationItems/donationItems.controller.ts +++ b/apps/backend/src/donationItems/donationItems.controller.ts @@ -5,16 +5,19 @@ import { Param, Get, Patch, + UseGuards, ParseIntPipe, BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { DonationItemsService } from './donationItems.service'; import { DonationItem } from './donationItems.entity'; +import { AuthGuard } from '@nestjs/passport'; import { FoodType } from './types'; @Controller('donation-items') //@UseInterceptors() +@UseGuards(AuthGuard('jwt')) export class DonationItemsController { constructor(private donationItemsService: DonationItemsService) {} diff --git a/apps/backend/src/donationItems/donationItems.module.ts b/apps/backend/src/donationItems/donationItems.module.ts index a416372f..ef377d2b 100644 --- a/apps/backend/src/donationItems/donationItems.module.ts +++ b/apps/backend/src/donationItems/donationItems.module.ts @@ -2,14 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DonationItemsService } from './donationItems.service'; import { DonationItem } from './donationItems.entity'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { DonationItemsController } from './donationItems.controller'; +import { AuthModule } from '../auth/auth.module'; import { Donation } from '../donations/donations.entity'; @Module({ - imports: [TypeOrmModule.forFeature([DonationItem, Donation])], + imports: [TypeOrmModule.forFeature([DonationItem, Donation]), AuthModule], controllers: [DonationItemsController], - providers: [DonationItemsService, AuthService, JwtStrategy], + providers: [DonationItemsService], }) export class DonationItemsModule {} diff --git a/apps/backend/src/donations/donations.controller.ts b/apps/backend/src/donations/donations.controller.ts index 6bcd2a7e..ad09b322 100644 --- a/apps/backend/src/donations/donations.controller.ts +++ b/apps/backend/src/donations/donations.controller.ts @@ -6,12 +6,14 @@ import { Patch, Param, NotFoundException, + UseGuards, ParseIntPipe, BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; +import { AuthGuard } from '@nestjs/passport'; import { DonationStatus } from './types'; @Controller('donations') diff --git a/apps/backend/src/donations/donations.module.ts b/apps/backend/src/donations/donations.module.ts index 311971d1..79ee1a2e 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -1,19 +1,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; import { DonationsController } from './donations.controller'; import { ManufacturerModule } from '../foodManufacturers/manufacturer.module'; +import { AuthModule } from '../auth/auth.module'; import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity'; @Module({ imports: [ TypeOrmModule.forFeature([Donation, FoodManufacturer]), ManufacturerModule, + AuthModule, ], controllers: [DonationsController], - providers: [DonationService, AuthService, JwtStrategy], + providers: [DonationService], }) export class DonationModule {} diff --git a/apps/backend/src/foodManufacturers/manufacturer.module.ts b/apps/backend/src/foodManufacturers/manufacturer.module.ts index 2ba2b117..dcb5a32f 100644 --- a/apps/backend/src/foodManufacturers/manufacturer.module.ts +++ b/apps/backend/src/foodManufacturers/manufacturer.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FoodManufacturer } from './manufacturer.entity'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([FoodManufacturer])], + imports: [TypeOrmModule.forFeature([FoodManufacturer]), AuthModule], }) export class ManufacturerModule {} diff --git a/apps/backend/src/foodRequests/request.controller.ts b/apps/backend/src/foodRequests/request.controller.ts index 1f449491..1162329e 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -7,6 +7,7 @@ import { Body, UploadedFiles, UseInterceptors, + UseGuards, BadRequestException, } from '@nestjs/common'; import { ApiBody } from '@nestjs/swagger'; @@ -15,6 +16,10 @@ import { FoodRequest } from './request.entity'; import { AWSS3Service } from '../aws/aws-s3.service'; import { FilesInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; +import { AuthGuard } from '@nestjs/passport'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../users/types'; +import { RolesGuard } from '../auth/roles.guard'; import { OrdersService } from '../orders/order.service'; import { Order } from '../orders/order.entity'; import { RequestSize } from './types'; @@ -22,6 +27,7 @@ import { OrderStatus } from '../orders/types'; @Controller('requests') // @UseInterceptors() +@UseGuards(RolesGuard) export class RequestsController { constructor( private requestsService: RequestsService, @@ -29,6 +35,7 @@ export class RequestsController { private ordersService: OrdersService, ) {} + @Roles(Role.PANTRY) @Get('/:requestId') async getRequest( @Param('requestId', ParseIntPipe) requestId: number, @@ -43,6 +50,7 @@ export class RequestsController { return this.requestsService.find(pantryId); } + @Roles(Role.PANTRY) @Post('/create') @ApiBody({ description: 'Details for creating a food request', @@ -109,6 +117,7 @@ export class RequestsController { ); } + @Roles(Role.PANTRY) @Post('/:requestId/confirm-delivery') @ApiBody({ description: 'Details for a confirmation form', diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts index 14a605d8..0e5dc280 100644 --- a/apps/backend/src/foodRequests/request.module.ts +++ b/apps/backend/src/foodRequests/request.module.ts @@ -3,10 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { RequestsController } from './request.controller'; import { FoodRequest } from './request.entity'; import { RequestsService } from './request.service'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { AWSS3Module } from '../aws/aws-s3.module'; import { MulterModule } from '@nestjs/platform-express'; +import { AuthModule } from '../auth/auth.module'; import { OrdersService } from '../orders/order.service'; import { Order } from '../orders/order.entity'; import { Pantry } from '../pantries/pantries.entity'; @@ -16,8 +15,9 @@ import { Pantry } from '../pantries/pantries.entity'; AWSS3Module, MulterModule.register({ dest: './uploads' }), TypeOrmModule.forFeature([FoodRequest, Order, Pantry]), + AuthModule, ], controllers: [RequestsController], - providers: [RequestsService, OrdersService, AuthService, JwtStrategy], + providers: [RequestsService, OrdersService], }) export class RequestsModule {} diff --git a/apps/backend/src/interceptors/current-user.interceptor.ts b/apps/backend/src/interceptors/current-user.interceptor.ts index e60b545a..d395b47a 100644 --- a/apps/backend/src/interceptors/current-user.interceptor.ts +++ b/apps/backend/src/interceptors/current-user.interceptor.ts @@ -16,18 +16,11 @@ export class CurrentUserInterceptor implements NestInterceptor { async intercept(context: ExecutionContext, handler: CallHandler) { const request = context.switchToHttp().getRequest(); - const cognitoUserAttributes = await this.authService.getUser( - request.user.userId, - ); - const userEmail = cognitoUserAttributes.find( - (attribute) => attribute.Name === 'email', - ).Value; - const users = await this.usersService.find(userEmail); - if (users.length > 0) { - const user = users[0]; - - request.user = user; + if (request.user) { + const dbUser = await this.usersService.findUserByCognitoId(request.user.sub); + console.log(dbUser); + request.currentUser = dbUser; } return handler.handle(); diff --git a/apps/backend/src/migrations/1769189327767-AddUserPoolId.ts b/apps/backend/src/migrations/1769189327767-AddUserPoolId.ts new file mode 100644 index 00000000..379d0506 --- /dev/null +++ b/apps/backend/src/migrations/1769189327767-AddUserPoolId.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserPoolId1769189327767 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE users + ADD COLUMN IF NOT EXISTS user_cognito_sub VARCHAR(255) NOT NULL DEFAULT '';` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE users DROP COLUMN IF EXISTS user_cognito_sub;` + ); + } + +} diff --git a/apps/backend/src/orders/order.module.ts b/apps/backend/src/orders/order.module.ts index c6b307a0..ca2c5ddd 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -1,16 +1,19 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OrdersController } from './order.controller'; import { Order } from './order.entity'; import { OrdersService } from './order.service'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { AuthService } from '../auth/auth.service'; import { AllocationModule } from '../allocations/allocations.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Order]), AllocationModule], + imports: [ + TypeOrmModule.forFeature([Order]), + AllocationModule, + forwardRef(() => AuthModule), + ], controllers: [OrdersController], - providers: [OrdersService, AuthService, JwtStrategy], + providers: [OrdersService], exports: [OrdersService], }) export class OrdersModule {} diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index ee8287ce..fda6d732 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -5,10 +5,15 @@ import { Param, ParseIntPipe, Post, - ValidationPipe, + UseGuards, } from '@nestjs/common'; import { Pantry } from './pantries.entity'; import { PantriesService } from './pantries.service'; +import { RolesGuard } from '../auth/roles.guard'; +import { Role } from '../users/types'; +import { Roles } from '../auth/roles.decorator'; +import { AuthGuard } from '@nestjs/passport'; +import { ValidationPipe } from '@nestjs/common'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { ApiBody } from '@nestjs/swagger'; import { @@ -22,6 +27,7 @@ import { import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; +@UseGuards(AuthGuard('jwt'), RolesGuard) @Controller('pantries') export class PantriesController { constructor( @@ -29,11 +35,13 @@ export class PantriesController { private ordersService: OrdersService, ) {} + @Roles(Role.VOLUNTEER) @Get('/pending') async getPendingPantries(): Promise { return this.pantriesService.getPendingPantries(); } + @Roles(Role.PANTRY, Role.ADMIN) @Get('/:pantryId') async getPantry( @Param('pantryId', ParseIntPipe) pantryId: number, @@ -41,6 +49,7 @@ export class PantriesController { return this.pantriesService.findOne(pantryId); } + @Roles(Role.ADMIN) @Get('/:pantryId/orders') async getOrders( @Param('pantryId', ParseIntPipe) pantryId: number, @@ -219,6 +228,7 @@ export class PantriesController { return this.pantriesService.approve(pantryId); } + @Roles(Role.ADMIN) @Post('/deny/:pantryId') async denyPantry( @Param('pantryId', ParseIntPipe) pantryId: number, diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index 3de2a4c5..5e60b78d 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -1,12 +1,18 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PantriesService } from './pantries.service'; import { PantriesController } from './pantries.controller'; import { Pantry } from './pantries.entity'; +import { AuthModule } from '../auth/auth.module'; import { OrdersModule } from '../orders/order.module'; +import { User } from '../users/user.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Pantry]), OrdersModule], + imports: [ + TypeOrmModule.forFeature([Pantry, User]), + OrdersModule, + forwardRef(() => AuthModule), + ], controllers: [PantriesController], providers: [PantriesService], exports: [PantriesService], diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index 746484ce..4481b22d 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -38,6 +38,14 @@ export class User { }) phone: string; + @Column({ + type: 'varchar', + length: 255, + name: 'user_cognito_sub', + default: '', + }) + userCognitoSub: string; + @ManyToMany(() => Pantry, (pantry) => pantry.volunteers) @JoinTable({ name: 'volunteer_assignments', diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 6f11265d..7040fc37 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -8,19 +8,14 @@ import { Post, BadRequestException, Body, - //UseGuards, - //UseInterceptors, } from '@nestjs/common'; import { UsersService } from './users.service'; -//import { AuthGuard } from '@nestjs/passport'; import { User } from './user.entity'; import { Role } from './types'; import { userSchemaDto } from './dtos/userSchema.dto'; import { Pantry } from '../pantries/pantries.entity'; -//import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; @Controller('users') -//@UseInterceptors(CurrentUserInterceptor) export class UsersController { constructor(private usersService: UsersService) {} @@ -31,7 +26,6 @@ export class UsersController { return this.usersService.getVolunteersAndPantryAssignments(); } - // @UseGuards(AuthGuard('jwt')) @Get('/:id') async getUser(@Param('id', ParseIntPipe) userId: number): Promise { return this.usersService.findOne(userId); diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index 6a780a8d..23177621 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -1,17 +1,19 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { User } from './user.entity'; -import { JwtStrategy } from '../auth/jwt.strategy'; -import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; -import { AuthService } from '../auth/auth.service'; import { PantriesModule } from '../pantries/pantries.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([User]), PantriesModule], - exports: [UsersService], + imports: [ + TypeOrmModule.forFeature([User]), + forwardRef(() => PantriesModule), + forwardRef(() => AuthModule), + ], controllers: [UsersController], - providers: [UsersService, AuthService, JwtStrategy, CurrentUserInterceptor], + providers: [UsersService], + exports: [UsersService], }) export class UsersModule {} diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 65f90ae1..0c57f3ea 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -5,7 +5,6 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; - import { User } from './user.entity'; import { Role } from './types'; import { validateId } from '../utils/validation.utils'; @@ -66,8 +65,8 @@ export class UsersService { return volunteer; } - find(email: string) { - return this.repo.find({ where: { email } }); + async findByEmail(email: string): Promise { + return this.repo.findOneBy({ email }); } async update(id: number, attrs: Partial) { @@ -139,4 +138,12 @@ export class UsersService { volunteer.pantries = [...volunteer.pantries, ...newPantries]; return this.repo.save(volunteer); } -} + + async findUserByCognitoId(cognitoId: string): Promise { + const user = await this.repo.findOneBy({ userCognitoSub: cognitoId }); + if (!user) { + throw new NotFoundException(`User with cognitoId ${cognitoId} not found`); + } + return user; + } +} \ No newline at end of file diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index e20724bb..d2ecb224 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -1,4 +1,9 @@ -import axios, { type AxiosInstance, AxiosResponse } from 'axios'; +import axios, { + AxiosError, + AxiosResponse, + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios'; import { User, Order, @@ -19,9 +24,40 @@ const defaultBaseUrl = export class ApiClient { private axiosInstance: AxiosInstance; + private accessToken: string | undefined; constructor() { this.axiosInstance = axios.create({ baseURL: defaultBaseUrl }); + + // Attach the access token to each request if available + // All API requests will go through this interceptor, making the user required to login + this.axiosInstance.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const token = this.accessToken || localStorage.getItem('accessToken'); + console.log('Attaching token to request:', token); + if (token) { + config.headers = config.headers || {}; + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error), + ); + + this.axiosInstance.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response?.status === 403) { + // TODO: For a future ticket, figure out a better method than renavigation on failure (or a better place to check than in the api requests) + window.location.replace('/unauthorized'); + } + return Promise.reject(error); + }, + ); + } + + public setAccessToken(token: string | undefined) { + this.accessToken = token; } public async getHello(): Promise { @@ -229,10 +265,21 @@ export class ApiClient { requestId: number, data: FormData, ): Promise { - await this.axiosInstance.post( - `/api/requests/${requestId}/confirm-delivery`, - data, - ); + try { + const response = await this.axiosInstance.post( + `/api/requests/${requestId}/confirm-delivery`, + data, + ); + + if (response.status === 200) { + alert('Delivery confirmation submitted successfully'); + window.location.href = '/request-form/1'; + } else { + alert(`Failed to submit: ${response.statusText}`); + } + } catch (error) { + alert(`Error submitting delivery confirmation: ${error}`); + } } } diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index e31dc284..9490cc78 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -1,7 +1,4 @@ -import { useEffect } from 'react'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; - -import apiClient from '@api/apiClient'; import Root from '@containers/root'; import NotFound from '@containers/404'; import LandingPage from '@containers/landingPage'; @@ -29,6 +26,7 @@ import { Authenticator } from '@aws-amplify/ui-react'; import { Amplify } from 'aws-amplify'; import CognitoAuthConfig from './aws-exports'; import { Button } from '@chakra-ui/react'; +import Unauthorized from '@containers/unauthorized'; Amplify.configure(CognitoAuthConfig); @@ -82,6 +80,54 @@ const router = createBrowserRouter([ element: , action: submitPantryApplicationForm, }, + + { + path: '/unauthorized', + element: , + }, + + // Private routes (protected by auth) + + { + path: '/landing-page', + element: ( + + + + ), + }, + { + path: '/pantry-overview', + element: ( + + + + ), + }, + { + path: '/pantry-dashboard/:pantryId', + element: ( + + + + ), + }, + { + path: '/pantry-past-orders', + element: ( + + + + ), + }, + { + path: '/pantries', + element: ( + + + + ), + }, { path: '/pantry-application/submitted', element: , @@ -143,21 +189,20 @@ const router = createBrowserRouter([ ), - loader: pantryIdLoader, }, { - path: '/donation-management', + path: '/approve-pantries', element: ( - + ), }, { - path: '/approve-pantries', + path: '/donation-management', element: ( - + ), }, @@ -199,10 +244,10 @@ const router = createBrowserRouter([ ]); export const App: React.FC = () => { - useEffect(() => { - document.title = 'SSF'; - apiClient.getHello().then((res) => console.log(res)); - }, []); + // useEffect(() => { + // document.title = 'SSF'; + // apiClient.getHello().then((res) => console.log(res)); + // }, []); return ( diff --git a/apps/frontend/src/aws-exports.ts b/apps/frontend/src/aws-exports.ts index e27da0b4..ad17bcd3 100644 --- a/apps/frontend/src/aws-exports.ts +++ b/apps/frontend/src/aws-exports.ts @@ -4,6 +4,9 @@ const CognitoAuthConfig = { userPoolClientId: '198bdfe995p1kb4jnopt3sk6i1', userPoolId: 'us-east-1_StSYXMibq', region: 'us-east-1', + loginWith: { + email: true, + }, }, }, }; diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index bbe3882a..f2042add 100644 --- a/apps/frontend/src/containers/approvePantries.tsx +++ b/apps/frontend/src/containers/approvePantries.tsx @@ -22,8 +22,8 @@ const ApprovePantries: React.FC = () => { try { const data = await ApiClient.getAllPendingPantries(); setPendingPantries(data); - } catch (error) { - alert('Error fetching unapproved pantries: ' + error); + } catch (err) { + console.log(err); } }; diff --git a/apps/frontend/src/containers/landingPage.tsx b/apps/frontend/src/containers/landingPage.tsx index 0e1a72b4..98bbd7ed 100644 --- a/apps/frontend/src/containers/landingPage.tsx +++ b/apps/frontend/src/containers/landingPage.tsx @@ -1,5 +1,18 @@ +import { Button } from '@chakra-ui/react'; +import SignOutButton from '@components/signOutButton'; +import { signOut } from 'aws-amplify/auth'; + const LandingPage: React.FC = () => { - return <>Landing page; + const handleSignOut = async () => { + await signOut(); + }; + + return ( + <> + Landing page + + + ); }; export default LandingPage; diff --git a/apps/frontend/src/containers/root.tsx b/apps/frontend/src/containers/root.tsx index dea053b1..d179353f 100644 --- a/apps/frontend/src/containers/root.tsx +++ b/apps/frontend/src/containers/root.tsx @@ -1,6 +1,10 @@ import { Outlet } from 'react-router-dom'; import Header from '../components/Header'; +import { useAuth } from '../hooks/useAuth'; + const Root: React.FC = () => { + useAuth(); + return (
diff --git a/apps/frontend/src/containers/unauthorized.tsx b/apps/frontend/src/containers/unauthorized.tsx new file mode 100644 index 00000000..83c687b7 --- /dev/null +++ b/apps/frontend/src/containers/unauthorized.tsx @@ -0,0 +1,10 @@ +export const Unauthorized: React.FC = () => { + return ( +
+

Oops!

+

You are not an authorized user for this page!

+
+ ); +}; + +export default Unauthorized; diff --git a/apps/frontend/src/hooks/useAuth.ts b/apps/frontend/src/hooks/useAuth.ts new file mode 100644 index 00000000..e9793761 --- /dev/null +++ b/apps/frontend/src/hooks/useAuth.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { fetchAuthSession } from 'aws-amplify/auth'; +import apiClient from '@api/apiClient'; + +// Hook to manage authentication state and set the API client's access token +export function useAuth() { + useEffect(() => { + const updateToken = async () => { + try { + const session = await fetchAuthSession(); + const idToken = session.tokens?.idToken?.toString(); + apiClient.setAccessToken(idToken); + } catch (error) { + console.error('Error fetching auth session:', error); + apiClient.setAccessToken(undefined); + } + }; + + updateToken(); + }, []); +}