diff --git a/.env.example b/.env.example index 74b39ad5b..8fa7b48f4 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,11 @@ DATABASE_PORT=5432 DATABASE_NAME=securing-safe-food DATABASE_NAME_TEST=securing-safe-food-test 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/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 0bd508da1..2a4196245 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -12,6 +12,11 @@ jobs: POSTGRES_PASSWORD: postgres ports: - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: DATABASE_HOST: 127.0.0.1 DATABASE_PORT: 5432 @@ -19,12 +24,13 @@ jobs: DATABASE_USERNAME: postgres DATABASE_PASSWORD: postgres NX_DAEMON: 'false' + CYPRESS_INSTALL_BINARY: '0' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - - run: yarn install - - run: yarn test - + - run: yarn install --frozen-lockfile + - run: yarn list strip-ansi string-width string-length + - run: yarn test \ No newline at end of file diff --git a/apps/backend/README.md b/apps/backend/README.md index 9eec729f5..d2b154bc0 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -27,10 +27,48 @@ You can check that your database connection details are correct by running `nx s 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 + ### Running backend tests 1. Create a **separate** Postgres database (for example `securing-safe-food-test`). 2. Add a `DATABASE_NAME_TEST` entry (and optionally `DATABASE_HOST/PORT/USERNAME/PASSWORD`) to your `.env` so the test data source can connect to that database. 3. Run the backend test suite with `npx jest`. -Each spec builds up the database and tables, tears it all down, and runs all the migrations on each tests. This ensures that we always have the most up to date data that we test with. \ No newline at end of file +Each spec builds up the database and tables, tears it all down, and runs all the migrations on each tests. This ensures that we always have the most up to date data that we test with. diff --git a/apps/backend/src/allocations/allocations.module.ts b/apps/backend/src/allocations/allocations.module.ts index fed7360bf..3284e1afd 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/app.module.ts b/apps/backend/src/app.module.ts index 6462b7435..7fe3ff82e 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -14,6 +14,9 @@ import { ManufacturerModule } from './foodManufacturers/manufacturers.module'; import { DonationModule } from './donations/donations.module'; import { DonationItemsModule } from './donationItems/donationItems.module'; import { AllocationModule } from './allocations/allocations.module'; +import { APP_GUARD } from '@nestjs/core'; +import { RolesGuard } from './auth/roles.guard'; +import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ @@ -42,6 +45,16 @@ import { ScheduleModule } from '@nestjs/schedule'; AllocationModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + ], }) export class AppModule {} diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index eac8a5b6b..3af03db00 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 a0bae3ad1..5ebb427e6 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 48541a193..97711cea2 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-auth.guard.ts b/apps/backend/src/auth/jwt-auth.guard.ts new file mode 100644 index 000000000..d15f42389 --- /dev/null +++ b/apps/backend/src/auth/jwt-auth.guard.ts @@ -0,0 +1,23 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from './public.decorator'; + +// Extension onto AuthGuard to add public route handling +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) return true; + + return super.canActivate(context); + } +} diff --git a/apps/backend/src/auth/jwt-payload.interface.ts b/apps/backend/src/auth/jwt-payload.interface.ts new file mode 100644 index 000000000..cf1ca846b --- /dev/null +++ b/apps/backend/src/auth/jwt-payload.interface.ts @@ -0,0 +1,9 @@ +export interface CognitoJwtPayload { + sub: string; + email?: string; + username?: string; + aud?: string; + iss?: string; + exp?: number; + iat?: number; +} diff --git a/apps/backend/src/auth/jwt.strategy.ts b/apps/backend/src/auth/jwt.strategy.ts index 44d8789d4..906773f41 100644 --- a/apps/backend/src/auth/jwt.strategy.ts +++ b/apps/backend/src/auth/jwt.strategy.ts @@ -2,30 +2,35 @@ import { Injectable } 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 { CognitoJwtPayload } from './jwt-payload.interface'; +import { User } from '../users/user.entity'; @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, issuer: cognitoAuthority, algorithms: ['RS256'], secretOrKeyProvider: passportJwtSecret({ cache: true, rateLimit: true, jwksRequestsPerMinute: 5, - jwksUri: cognitoAuthority + '/.well-known/jwks.json', + jwksUri: `${cognitoAuthority}/.well-known/jwks.json`, }), }); } - async validate(payload) { - return { idUser: payload.sub, email: payload.email }; + // This function is natively called when we validate a JWT token + // Afer confirming that our jwt is valid and our payload is signed, + // we use the sub field in the payload to find the user in our database + async validate(payload: CognitoJwtPayload): Promise { + const dbUser = await this.usersService.findUserByCognitoId(payload.sub); + return dbUser; } } diff --git a/apps/backend/src/auth/public.decorator.ts b/apps/backend/src/auth/public.decorator.ts new file mode 100644 index 000000000..b3845e122 --- /dev/null +++ b/apps/backend/src/auth/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/backend/src/auth/roles.decorator.ts b/apps/backend/src/auth/roles.decorator.ts new file mode 100644 index 000000000..fb7eeb919 --- /dev/null +++ b/apps/backend/src/auth/roles.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; +import { Role } from '../users/types'; + +// Key used to store roles metadata +export const ROLES_KEY = 'roles'; +// Custom decorator to set roles metadata on route handlers for proper parsing by RolesGuard +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 000000000..2dd548686 --- /dev/null +++ b/apps/backend/src/auth/roles.guard.ts @@ -0,0 +1,37 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Role } from '../users/types'; +import { ROLES_KEY } from './roles.decorator'; + +// Guard to enforce role-based access control on route handlers +// Applies logic to get us our user, and compare it with the required roles +// Interacts with the metadata that we attach in the @Roles() decorator +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + // If this returns false, Nest will deny access to the route handler + // Automatically throwing a Forbidden Exception (403 status code) + canActivate(context: ExecutionContext): boolean { + // Look for the metadata we set with the @Roles() decorator + // Checks in the route handler, then the controller, and makes it undefined if nothing found + // Routes take priority over controllers in terms of overriding + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), // method-level + context.getClass(), // controller-level + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !user.role) { + return false; + } + + return requiredRoles.includes(user.role); + } +} diff --git a/apps/backend/src/config/migrations.ts b/apps/backend/src/config/migrations.ts index e7177b51a..a818f46d6 100644 --- a/apps/backend/src/config/migrations.ts +++ b/apps/backend/src/config/migrations.ts @@ -26,6 +26,7 @@ import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-Upd import { PopulateDummyData1768501812134 } from '../migrations/1768501812134-populateDummyData'; import { RemovePantryFromOrders1769316004958 } from '../migrations/1769316004958-RemovePantryFromOrders'; import { UpdateManufacturerEntity1768680807820 } from '../migrations/1768680807820-UpdateManufacturerEntity'; +import { AddUserPoolId1769189327767 } from '../migrations/1769189327767-AddUserPoolId'; const schemaMigrations = [ User1725726359198, @@ -56,6 +57,7 @@ const schemaMigrations = [ PopulateDummyData1768501812134, RemovePantryFromOrders1769316004958, UpdateManufacturerEntity1768680807820, + AddUserPoolId1769189327767, ]; export default schemaMigrations; diff --git a/apps/backend/src/donationItems/donationItems.module.ts b/apps/backend/src/donationItems/donationItems.module.ts index a416372fc..ef377d2ba 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.module.ts b/apps/backend/src/donations/donations.module.ts index d6fe7c101..55f74e60b 100644 --- a/apps/backend/src/donations/donations.module.ts +++ b/apps/backend/src/donations/donations.module.ts @@ -1,11 +1,10 @@ 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/manufacturers.module'; +import { ManufacturerModule } from '../foodManufacturers/manufacturer.module'; +import { AuthModule } from '../auth/auth.module'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { DonationsSchedulerService } from './donations.scheduler'; @@ -13,13 +12,9 @@ import { DonationsSchedulerService } from './donations.scheduler'; imports: [ TypeOrmModule.forFeature([Donation, FoodManufacturer]), ManufacturerModule, + AuthModule, ], controllers: [DonationsController], - providers: [ - DonationService, - AuthService, - JwtStrategy, - DonationsSchedulerService, - ], + providers: [DonationService, DonationsSchedulerService], }) export class DonationModule {} diff --git a/apps/backend/src/foodManufacturers/manufacturer.module.ts b/apps/backend/src/foodManufacturers/manufacturer.module.ts new file mode 100644 index 000000000..20d30bbac --- /dev/null +++ b/apps/backend/src/foodManufacturers/manufacturer.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FoodManufacturer } from './manufacturers.entity'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + 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 ec6dc0f04..714a37aee 100644 --- a/apps/backend/src/foodRequests/request.controller.ts +++ b/apps/backend/src/foodRequests/request.controller.ts @@ -16,6 +16,8 @@ import { FoodRequest } from './request.entity'; import { AWSS3Service } from '../aws/aws-s3.service'; import { FilesInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../users/types'; import { OrdersService } from '../orders/order.service'; import { RequestSize } from './types'; import { OrderStatus } from '../orders/types'; @@ -30,6 +32,7 @@ export class RequestsController { private ordersService: OrdersService, ) {} + @Roles(Role.PANTRY, Role.ADMIN) @Get('/:requestId') async getRequest( @Param('requestId', ParseIntPipe) requestId: number, @@ -37,6 +40,7 @@ export class RequestsController { return this.requestsService.findOne(requestId); } + @Roles(Role.PANTRY, Role.ADMIN) @Get('/get-all-requests/:pantryId') async getAllPantryRequests( @Param('pantryId', ParseIntPipe) pantryId: number, @@ -117,6 +121,7 @@ export class RequestsController { ); } + @Roles(Role.PANTRY, Role.ADMIN) //TODO: delete endpoint, here temporarily as a logic reference for order status impl. @Post('/:requestId/confirm-delivery') @ApiBody({ diff --git a/apps/backend/src/foodRequests/request.module.ts b/apps/backend/src/foodRequests/request.module.ts index 14a605d80..0e5dc2803 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 deleted file mode 100644 index e60b545a9..000000000 --- a/apps/backend/src/interceptors/current-user.interceptor.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, -} from '@nestjs/common'; -import { AuthService } from '../auth/auth.service'; -import { UsersService } from '../users/users.service'; - -@Injectable() -export class CurrentUserInterceptor implements NestInterceptor { - constructor( - private authService: AuthService, - private usersService: UsersService, - ) {} - - 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; - } - - 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 000000000..7763af075 --- /dev/null +++ b/apps/backend/src/migrations/1769189327767-AddUserPoolId.ts @@ -0,0 +1,16 @@ +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 4937eced7..0734f8760 100644 --- a/apps/backend/src/orders/order.module.ts +++ b/apps/backend/src/orders/order.module.ts @@ -1,4 +1,4 @@ -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'; @@ -7,11 +7,16 @@ import { JwtStrategy } from '../auth/jwt.strategy'; import { AuthService } from '../auth/auth.service'; import { Pantry } from '../pantries/pantries.entity'; import { AllocationModule } from '../allocations/allocations.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Order, Pantry]), AllocationModule], + imports: [ + TypeOrmModule.forFeature([Order, Pantry]), + 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 3ead58bc0..7ed7764fd 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -6,10 +6,12 @@ import { ParseIntPipe, Patch, Post, - ValidationPipe, } from '@nestjs/common'; import { Pantry } from './pantries.entity'; import { PantriesService } from './pantries.service'; +import { Role } from '../users/types'; +import { Roles } from '../auth/roles.decorator'; +import { ValidationPipe } from '@nestjs/common'; import { PantryApplicationDto } from './dtos/pantry-application.dto'; import { ApiBody } from '@nestjs/swagger'; import { @@ -22,6 +24,7 @@ import { } from './types'; import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; +import { Public } from '../auth/public.decorator'; @Controller('pantries') export class PantriesController { @@ -30,11 +33,13 @@ export class PantriesController { private ordersService: OrdersService, ) {} + @Roles(Role.ADMIN) @Get('/pending') async getPendingPantries(): Promise { return this.pantriesService.getPendingPantries(); } + @Roles(Role.PANTRY, Role.ADMIN) @Get('/:pantryId') async getPantry( @Param('pantryId', ParseIntPipe) pantryId: number, @@ -42,6 +47,7 @@ export class PantriesController { return this.pantriesService.findOne(pantryId); } + @Roles(Role.ADMIN, Role.PANTRY) @Get('/:pantryId/orders') async getOrders( @Param('pantryId', ParseIntPipe) pantryId: number, @@ -284,6 +290,7 @@ export class PantriesController { ], }, }) + @Public() @Post() async submitPantryApplication( @Body(new ValidationPipe()) @@ -292,6 +299,7 @@ export class PantriesController { return this.pantriesService.addPantry(pantryData); } + @Roles(Role.ADMIN) @Patch('/:pantryId/approve') async approvePantry( @Param('pantryId', ParseIntPipe) pantryId: number, @@ -299,6 +307,7 @@ export class PantriesController { return this.pantriesService.approve(pantryId); } + @Roles(Role.ADMIN) @Patch('/:pantryId/deny') 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 3de2a4c50..5e60b78d2 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 746484cea..4481b22dc 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 6f11265d9..7040fc373 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 6a780a8d6..23177621e 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.spec.ts b/apps/backend/src/users/users.service.spec.ts index 3cb9b77a9..64145f5a4 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -13,7 +13,7 @@ import { PantriesService } from '../pantries/pantries.service'; const mockUserRepository = mock>(); const mockPantriesService = mock(); -const mockUser: User = { +const mockUser: Partial = { id: 1, email: 'test@example.com', firstName: 'John', @@ -103,7 +103,7 @@ describe('UsersService', () => { describe('findOne', () => { it('should return a user by id', async () => { - mockUserRepository.findOneBy.mockResolvedValue(mockUser); + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); const result = await service.findOne(1); @@ -128,16 +128,15 @@ describe('UsersService', () => { }); }); - describe('find', () => { - it('should return users by email', async () => { - const users = [mockUser]; - mockUserRepository.find.mockResolvedValue(users); + describe('findByEmail', () => { + it('should return user by email', async () => { + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); - const result = await service.find('test@example.com'); + const result = await service.findByEmail('test@example.com'); - expect(result).toEqual(users); - expect(mockUserRepository.find).toHaveBeenCalledWith({ - where: { email: 'test@example.com' }, + expect(result).toEqual(mockUser); + expect(mockUserRepository.findOneBy).toHaveBeenCalledWith({ + email: 'test@example.com', }); }); }); @@ -147,8 +146,8 @@ describe('UsersService', () => { const updateData = { firstName: 'Updated', role: Role.ADMIN }; const updatedUser = { ...mockUser, ...updateData }; - mockUserRepository.findOneBy.mockResolvedValue(mockUser); - mockUserRepository.save.mockResolvedValue(updatedUser); + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); + mockUserRepository.save.mockResolvedValue(updatedUser as User); const result = await service.update(1, updateData); @@ -175,8 +174,8 @@ describe('UsersService', () => { describe('remove', () => { it('should remove a user by id', async () => { - mockUserRepository.findOneBy.mockResolvedValue(mockUser); - mockUserRepository.remove.mockResolvedValue(mockUser); + mockUserRepository.findOneBy.mockResolvedValue(mockUser as User); + mockUserRepository.remove.mockResolvedValue(mockUser as User); const result = await service.remove(1); @@ -205,7 +204,7 @@ describe('UsersService', () => { it('should return users by roles', async () => { const roles = [Role.ADMIN, Role.VOLUNTEER]; const users = [mockUser]; - mockUserRepository.find.mockResolvedValue(users); + mockUserRepository.find.mockResolvedValue(users as User[]); const result = await service.findUsersByRoles(roles); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 65f90ae1e..8432ba1a1 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,12 @@ export class UsersService { return volunteer; } - find(email: string) { - return this.repo.find({ where: { email } }); + async findByEmail(email: string): Promise { + const user = await this.repo.findOneBy({ email }); + if (!user) { + throw new NotFoundException(`User with email ${email} not found`); + } + return user; } async update(id: number, attrs: Partial) { @@ -139,4 +142,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; + } } diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 7d33ae3e1..09a317451 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, @@ -21,13 +26,39 @@ 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'); + 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 async getHello(): Promise { - return this.get('/api') as Promise; + public setAccessToken(token: string | undefined) { + this.accessToken = token; } public async get(path: string): Promise { @@ -232,7 +263,6 @@ export class ApiClient { public async getPantryRequests(pantryId: number): Promise { const data = await this.get(`/api/requests/get-all-requests/${pantryId}`); - console.log('Raw response from API:', data); return data as FoodRequest[]; } @@ -240,10 +270,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 e31dc2846..0940a8532 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); @@ -86,6 +84,10 @@ const router = createBrowserRouter([ path: '/pantry-application/submitted', element: , }, + { + path: '/unauthorized', + element: , + }, // Private routes (protected by auth) { path: '/pantry-overview', @@ -95,6 +97,38 @@ const router = createBrowserRouter([ ), }, + { + path: '/pantry-dashboard/:pantryId', + element: ( + + + + ), + }, + { + path: '/pantry-past-orders', + element: ( + + + + ), + }, + { + path: '/pantries', + element: ( + + + + ), + }, + { + path: '/pantry-overview', + element: ( + + + + ), + }, { path: '/pantry-dashboard/:pantryId', element: ( @@ -146,18 +180,18 @@ const router = createBrowserRouter([ loader: pantryIdLoader, }, { - path: '/donation-management', + path: '/approve-pantries', element: ( - + ), }, { - path: '/approve-pantries', + path: '/donation-management', element: ( - + ), }, @@ -199,11 +233,6 @@ const router = createBrowserRouter([ ]); export const App: React.FC = () => { - 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 e27da0b43..ad17bcd30 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/components/Header.tsx b/apps/frontend/src/components/Header.tsx index 380244711..3187da554 100644 --- a/apps/frontend/src/components/Header.tsx +++ b/apps/frontend/src/components/Header.tsx @@ -1,11 +1,14 @@ import React from 'react'; import SignOutButton from './signOutButton'; +import { useAuthenticator } from '@aws-amplify/ui-react'; const Header = () => { + const { user } = useAuthenticator((context) => [context.user]); + return (

Securing Safe Food

- + {user && }
); }; diff --git a/apps/frontend/src/components/signOutButton.tsx b/apps/frontend/src/components/signOutButton.tsx index 1467ed251..8b2596b53 100644 --- a/apps/frontend/src/components/signOutButton.tsx +++ b/apps/frontend/src/components/signOutButton.tsx @@ -1,3 +1,4 @@ +import apiClient from '@api/apiClient'; import { Button, ButtonProps } from '@chakra-ui/react'; import { signOut } from 'aws-amplify/auth'; import { useNavigate } from 'react-router-dom'; @@ -9,6 +10,7 @@ const SignOutButton: React.FC = (props) => { const handleSignOut = async () => { await signOut(); + apiClient.setAccessToken(undefined); navigate('/'); }; diff --git a/apps/frontend/src/containers/FormRequests.tsx b/apps/frontend/src/containers/FormRequests.tsx index 8d5b2246d..d918e86f1 100644 --- a/apps/frontend/src/containers/FormRequests.tsx +++ b/apps/frontend/src/containers/FormRequests.tsx @@ -53,7 +53,7 @@ const FormRequests: React.FC = () => { setPreviousRequest(sortedData[0]); } } catch (error) { - alert('Error fetching requests: ' + error); + console.log(error); } } }; diff --git a/apps/frontend/src/containers/approvePantries.tsx b/apps/frontend/src/containers/approvePantries.tsx index bbe3882ad..d2bd59c85 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) { + alert(err); } }; diff --git a/apps/frontend/src/containers/landingPage.tsx b/apps/frontend/src/containers/landingPage.tsx index 0e1a72b4c..7ce581d6d 100644 --- a/apps/frontend/src/containers/landingPage.tsx +++ b/apps/frontend/src/containers/landingPage.tsx @@ -1,5 +1,14 @@ +import SignOutButton from '@components/signOutButton'; +import { useAuthenticator } from '@aws-amplify/ui-react'; + const LandingPage: React.FC = () => { - return <>Landing page; + const { user } = useAuthenticator((context) => [context.user]); + return ( + <> + Landing page + {user && } + + ); }; export default LandingPage; diff --git a/apps/frontend/src/containers/root.tsx b/apps/frontend/src/containers/root.tsx index dea053b17..d179353fc 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 000000000..83c687b7f --- /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 000000000..6faf76812 --- /dev/null +++ b/apps/frontend/src/hooks/useAuth.ts @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; +import { fetchAuthSession } from 'aws-amplify/auth'; +import { Hub, type HubCapsule } from 'aws-amplify/utils'; +import apiClient from '@api/apiClient'; + +interface AuthPayload { + event: 'signIn' | 'signOut' | 'tokenRefresh' | string; +} + +// 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 accessToken = session.tokens?.accessToken?.toString(); + + if (accessToken) { + apiClient.setAccessToken(accessToken); + localStorage.setItem('accessToken', accessToken); + } else { + apiClient.setAccessToken(undefined); + localStorage.removeItem('accessToken'); + } + } catch (error) { + console.error('Error fetching auth session:', error); + apiClient.setAccessToken(undefined); + localStorage.removeItem('accessToken'); + } + }; + + updateToken(); + + // Listen for auth events so we can update token immediately after sign in + const listener = (data: HubCapsule<'auth', AuthPayload>) => { + const { payload } = data; + if (payload.event === 'signIn' || payload.event === 'tokenRefresh') { + updateToken(); + } + + if (payload.event === 'signOut') { + apiClient.setAccessToken(undefined); + localStorage.removeItem('accessToken'); + } + }; + + const unsubscribe = Hub.listen('auth', listener); + + return () => { + unsubscribe(); + }; + }, []); +} diff --git a/apps/frontend/src/loaders/pantryIdLoader.ts b/apps/frontend/src/loaders/pantryIdLoader.ts index cab476ead..608434120 100644 --- a/apps/frontend/src/loaders/pantryIdLoader.ts +++ b/apps/frontend/src/loaders/pantryIdLoader.ts @@ -1,6 +1,7 @@ -import { json, LoaderFunctionArgs } from 'react-router-dom'; +import { LoaderFunctionArgs } from 'react-router-dom'; import ApiClient from '@api/apiClient'; import { AxiosError } from 'axios'; +import { fetchAuthSession } from 'aws-amplify/auth'; export async function pantryIdLoader({ params }: LoaderFunctionArgs) { const { pantryId } = params; @@ -10,15 +11,30 @@ export async function pantryIdLoader({ params }: LoaderFunctionArgs) { } try { + // Fetch the auth session + const session = await fetchAuthSession({ forceRefresh: false }); + const idToken = session.tokens?.idToken?.toString(); + + // If no token, the user isn't authenticated yet, so let the Authenticator handle this + if (!idToken) { + return { pantry: null }; + } + + ApiClient.setAccessToken(idToken); + const pantry = await ApiClient.getPantry(parseInt(pantryId, 10)); - return json({ pantry }); + return { pantry }; } catch (error: unknown) { if (error instanceof AxiosError) { if (error.response?.status === 404) { throw new Response('Not Found', { status: 404 }); } + if (error.response?.status === 401 || error.response?.status === 403) { + // Auth error - return null and let component retry after auth + return { pantry: null }; + } } - throw new Response('Server Error', { status: 500 }); + throw new Response('Server Error: ', { status: 500 }); } }