diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index ab77991..72bd066 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -3,7 +3,7 @@ import { AuthService } from './auth.service'; import { User } from '../types/User'; import { Response } from 'express'; import { VerifyAdminRoleGuard, VerifyUserGuard } from "../guards/auth.guard"; -import { RegisterBody } from './types/auth.types'; +import { LoginBody, RegisterBody } from './types/auth.types'; import { ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; @Controller('auth') @@ -43,14 +43,14 @@ export class AuthController { status : 201, description : "User registered successfully" }) - @ApiResponse({ - status : 400, - description : "{Error encountered}"} - ) @ApiResponse({ status: 500, description : "Internal Server Error" }) + @ApiResponse({ + status : 400, + description : "{Error encountered}"} + ) @ApiResponse({ status: 409, description : "{Error encountered}" @@ -65,8 +65,7 @@ export class AuthController { @Post('login') async login( @Res({ passthrough: true }) response: Response, - @Body('username') username: string, - @Body('password') password: string, + @Body() body:LoginBody ): Promise<{ user: User; session?: string; @@ -75,7 +74,7 @@ export class AuthController { username?: string; position?: string; }> { - const result = await this.authService.login(username, password); + const result = await this.authService.login(body.username, body.password); // Set cookie with access token if (result.access_token) { diff --git a/backend/src/guards/auth.guard.ts b/backend/src/guards/auth.guard.ts index cfc9241..1c90761 100644 --- a/backend/src/guards/auth.guard.ts +++ b/backend/src/guards/auth.guard.ts @@ -73,6 +73,14 @@ export class VerifyAdminRoleGuard implements CanActivate { } const result = await this.verifier.verify(accessToken); const groups = result['cognito:groups'] || []; + + // Attach user info to request for use in controllers + request.user = { + userId: result['username'] || result['cognito:username'], + email: result['email'], + position: groups.includes('Admin') ? 'Admin' : (groups.includes('Employee') ? 'Employee' : 'Inactive') + }; + console.log("User groups from token:", groups); if (!groups.includes('Admin')) { console.warn("Access denied: User is not an Admin"); @@ -87,3 +95,57 @@ export class VerifyAdminRoleGuard implements CanActivate { } } } + +@Injectable() +export class VerifyAdminOrEmployeeRoleGuard implements CanActivate { + private verifier: any; + private readonly logger: Logger; + + constructor() { + const userPoolId = process.env.COGNITO_USER_POOL_ID; + this.logger = new Logger(VerifyAdminOrEmployeeRoleGuard.name); + + if (userPoolId) { + this.verifier = CognitoJwtVerifier.create({ + userPoolId, + tokenUse: "access", + clientId: process.env.COGNITO_CLIENT_ID, + }); + } else { + throw new Error( + "[AUTH] USER POOL ID is not defined in environment variables" + ); + } + } + + async canActivate(context: ExecutionContext): Promise { + try { + const request = context.switchToHttp().getRequest(); + const accessToken = request.cookies["access_token"]; + + if (!accessToken) { + this.logger.error("No access token found in cookies"); + return false; + } + + const result = await this.verifier.verify(accessToken); + const groups = result['cognito:groups'] || []; + + this.logger.log(`User groups from token: ${groups.join(', ')}`); + + // Check if user is either Admin or Employee + const isAuthorized = groups.includes('Admin') || groups.includes('Employee'); + + if (!isAuthorized) { + this.logger.warn("Access denied: User is not an Admin or Employee"); + return false; + } + + return true; + + } catch (error) { + this.logger.error("Token verification failed:", error); + return false; + } + } +} diff --git a/backend/src/user/__test__/user.service.spec.ts b/backend/src/user/__test__/user.service.spec.ts index bd7a992..1500da3 100644 --- a/backend/src/user/__test__/user.service.spec.ts +++ b/backend/src/user/__test__/user.service.spec.ts @@ -4,7 +4,7 @@ import { UserService } from '../user.service'; import * as AWS from 'aws-sdk'; -import { VerifyUserGuard, VerifyAdminRoleGuard } from '../../guards/auth.guard'; +import { VerifyUserGuard, VerifyAdminRoleGuard, VerifyAdminOrEmployeeRoleGuard } from '../../guards/auth.guard'; import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; // Create mock functions at module level (BEFORE mock) @@ -77,6 +77,9 @@ vi.mock('../../guards/auth.guard', () => ({ }), VerifyAdminRoleGuard: vi.fn(class MockVerifyAdminRoleGuard { canActivate = vi.fn().mockResolvedValue(true); + }), + VerifyAdminOrEmployeeRoleGuard: vi.fn(class MockVerifyAdminOrEmployeeRoleGuard { + canActivate = vi.fn().mockResolvedValue(true); }) })); diff --git a/backend/src/user/types/user.types.ts b/backend/src/user/types/user.types.ts new file mode 100644 index 0000000..8cd99c2 --- /dev/null +++ b/backend/src/user/types/user.types.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserStatus } from '../../../../middle-layer/types/UserStatus'; + +export class ChangeRoleBody { + user!: { + userId: string, + position: UserStatus, + email: string + }; + groupName!: UserStatus; +} \ No newline at end of file diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 8d72da9..b6a9772 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,60 +1,202 @@ -import { Controller, Get, Post, Body, Param, UseGuards } from "@nestjs/common"; +import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req } from "@nestjs/common"; import { UserService } from "./user.service"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; -import { VerifyAdminRoleGuard, VerifyUserGuard } from "../guards/auth.guard"; +import { VerifyAdminRoleGuard, VerifyUserGuard, VerifyAdminOrEmployeeRoleGuard } from "../guards/auth.guard"; +import { ApiResponse, ApiParam , ApiBearerAuth} from "@nestjs/swagger"; +import { ChangeRoleBody } from "./types/user.types"; @Controller("user") export class UserController { constructor(private readonly userService: UserService) {} + /** + * Get all users + */ @Get() - @UseGuards(VerifyUserGuard) + @ApiResponse({ + status : 200, + description : "All users retrieved successfully" + }) + @ApiResponse({ + status : 403, + description : "Forbidden" + }) + @ApiResponse({ + status : 500, + description : "Internal Server Error" + }) + @UseGuards(VerifyAdminOrEmployeeRoleGuard) + @ApiBearerAuth() async getAllUsers() { return await this.userService.getAllUsers(); } + + /** + * Get all inactive users + */ @Get("inactive") - @UseGuards(VerifyUserGuard) + @ApiResponse({ + status : 200, + description : "All inactive users retrieved successfully" + }) + @ApiResponse({ + status : 403, + description : "Forbidden" + }) + @ApiResponse({ + status : 500, + description : "Internal Server Error" + }) + @UseGuards(VerifyAdminOrEmployeeRoleGuard) + @ApiBearerAuth() async getAllInactiveUsers(): Promise { return await this.userService.getAllInactiveUsers(); } + /** + * Get all active users + */ @Get("active") - @UseGuards(VerifyUserGuard) + @ApiResponse({ + status : 200, + description : "All active users retrieved successfully" + }) + @ApiResponse({ + status : 403, + description : "Forbidden" + }) + @ApiResponse({ + status : 500, + description : "Internal Server Error" + }) + @UseGuards(VerifyAdminOrEmployeeRoleGuard) + @ApiBearerAuth() async getAllActiveUsers(): Promise { console.log("Fetching all active users"); return await this.userService.getAllActiveUsers(); } - // Make sure to put a guard on this route - @Post("change-role") + + /** + * Change a user's role (make sure guard is on this route) + */ + @Patch("change-role") + @ApiResponse({ + status : 200, + description : "User role changed successfully" + }) + @ApiResponse({ + status : 400, + description : "{Error encountered}" + }) + @ApiResponse({ + status : 401, + description : "Unauthorized" + }) + @ApiResponse({ + status : 403, + description : "Forbidden" + }) + @ApiResponse({ + status : 404, + description : "Not Found" + }) + @ApiResponse({ + status : 500, + description : "Internal Server Error" + }) @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() async addToGroup( - @Body("user") user: User, - @Body("groupName") groupName: UserStatus, - @Body("requestedBy") requestedBy: User + @Body() changeRoleBody: ChangeRoleBody, + @Req() req: any ): Promise { + // Get the requesting admin from the authenticated session (attached by guard) + const requestedBy: User = req.user; + let newUser: User = await this.userService.addUserToGroup( - user, - groupName, + changeRoleBody.user, + changeRoleBody.groupName, requestedBy ); return newUser as User; } - @Post("delete-user") + /** + * Delete a user + */ + @Delete("delete-user/:userId") + @ApiParam({ + name: 'userId', + description: 'ID of the user to delete', + required: true, + type: String + }) + @ApiResponse({ + status : 200, + description : "User deleted successfully" + }) + @ApiResponse({ + status : 400, + description : "{Error encountered}" + }) + @ApiResponse({ + status : 401, + description : "Unauthorized" + }) + @ApiResponse({ + status : 403, + description : "Forbidden" + }) + @ApiResponse({ + status : 404, + description : "Not Found" + }) + @ApiResponse({ + status : 500, + description : "Internal Server Error" + }) @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() async deleteUser( - @Body("user") user: User, - @Body("requestedBy") requestedBy: User + @Param('userId') userId: string, + @Req() req: any ): Promise { - let deletedUser = await this.userService.deleteUser(user, requestedBy); - return user as User; + // Get the requesting admin from the authenticated session (attached by guard) + const requestedBy: User = req.user; + + // Fetch the user to delete from the database + const userToDelete: User = await this.userService.getUserById(userId); + + return await this.userService.deleteUser(userToDelete, requestedBy); } + /** + * Get user by ID + */ @Get(":id") - @UseGuards(VerifyUserGuard) - async getUserById(@Param("id") userId: string) { + @ApiParam({ + name: 'id', + description: 'User ID to retrieve', + required: true, + type: String + }) + @ApiResponse({ + status : 200, + description : "User retrieved successfully" + }) + @ApiResponse({ + status : 403, + description : "Forbidden" + }) + @ApiResponse({ + status : 500, + description : "Internal Server Error" + }) + @UseGuards(VerifyAdminOrEmployeeRoleGuard) + @ApiBearerAuth() + async getUserById(@Param('id') userId: string): Promise { return await this.userService.getUserById(userId); } } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 032a373..ad46eed 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -23,6 +23,8 @@ export class UserService { private dynamoDb = new AWS.DynamoDB.DocumentClient(); private ses = new AWS.SES({ region: process.env.AWS_REGION }); + // purpose statement: deletes user from database; only admin can delete users + // use case: employee is no longer with BCAN async deleteUser(user: User, requestedBy: User): Promise { const userPoolId = process.env.COGNITO_USER_POOL_ID; const tableName = process.env.DYNAMODB_USER_TABLE_NAME; @@ -41,10 +43,12 @@ export class UserService { // 2. Validate input if (!user || !user.userId) { + this.logger.error("Invalid user object provided for deletion"); throw new BadRequestException("Valid user object is required"); } if (!requestedBy || !requestedBy.userId) { + this.logger.error("Invalid requesting user object provided for deletion"); throw new BadRequestException("Valid requesting user is required"); } @@ -58,6 +62,7 @@ export class UserService { // 4. Prevent self-deletion if (requestedBy.userId === username) { + this.logger.warn(`Administrator ${requestedBy.userId} attempted to delete their own account`); throw new BadRequestException("Administrators cannot delete their own account"); } @@ -97,6 +102,7 @@ export class UserService { const deleteResult = await this.dynamoDb.delete(deleteParams).promise(); if (!deleteResult.Attributes) { + this.logger.error(`DynamoDB delete did not return deleted attributes for ${username}`); throw new InternalServerErrorException( "Failed to delete user from database" ); @@ -177,19 +183,30 @@ export class UserService { return userToDelete; } - async getAllUsers(): Promise { +// purpose statement: retrieves all users from the database +// use case: admin wants to see all users on user management page +async getAllUsers(): Promise { const params = { TableName: process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE", }; try { + this.logger.log('Executing DynamoDB scan to retrieve all users...'); const data = await this.dynamoDb.scan(params).promise(); + + const userCount = data.Items?.length || 0; + this.logger.log(`✅ Successfully retrieved ${userCount} users from database`); + return data.Items; } catch (error) { - throw new Error("Could not retrieve users."); + this.logger.error('Failed to retrieve users from DynamoDB:', error); + throw new InternalServerErrorException("Could not retrieve users."); } } - async addUserToGroup( + + // purpose statement: adds user to a group/changes their role; only admin can do this + // use case: admin wants to promote an employee to admin or activate an inactive user +async addUserToGroup( user: User, groupName: UserStatus, requestedBy: User @@ -259,6 +276,9 @@ export class UserService { requestedBy.position === UserStatus.Admin && groupName !== UserStatus.Admin ) { + this.logger.warn( + `Administrator ${requestedBy.userId} attempted to demote themselves` + ); throw new BadRequestException( "Administrators cannot demote themselves" ); @@ -372,6 +392,9 @@ export class UserService { const result = await this.dynamoDb.update(params).promise(); if (!result.Attributes) { + this.logger.error( + `DynamoDB update did not return updated attributes for ${username}` + ); throw new InternalServerErrorException( "Failed to retrieve updated user data" ); @@ -439,7 +462,9 @@ export class UserService { } } - async getUserById(userId: string): Promise { + // purpose statement: retrieves user by their userId + // use case: not actually sure right now, maybe is there is an option for admin to click on a specific user to see details? + async getUserById(userId: string): Promise { const params = { TableName: process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE", Key: { @@ -448,15 +473,22 @@ export class UserService { }; try { + this.logger.log(`Fetching user ${userId} from DynamoDB...`); const data = await this.dynamoDb.get(params).promise(); - return data.Item; + + this.logger.log(`✅ Successfully retrieved user ${userId}`); + return data.Item as User; } catch (error) { - throw new Error('Could not retrieve user.'); + this.logger.error(`Failed to retrieve user ${userId} from DynamoDB:`, error); + throw new InternalServerErrorException('Could not retrieve user.'); } } + // purpose statement: retrieves all inactive users + // use case: for admin to see all users that need account approval on user management page (pending users tab) async getAllInactiveUsers(): Promise { this.logger.log("Fetching all inactive users in service"); + const params = { TableName: process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE", FilterExpression: "#pos IN (:inactive)", @@ -469,7 +501,9 @@ export class UserService { }; try { + this.logger.log('Executing DynamoDB scan with filter for Inactive users...'); const result = await this.dynamoDb.scan(params).promise(); + const users: User[] = (result.Items || []).map((item) => ({ userId: item.userId, // Assign name to userId position: item.position as UserStatus, @@ -477,6 +511,7 @@ export class UserService { name: item.userId, // Keep name as name })); + this.logger.log(`✅ Successfully retrieved ${users.length} inactive users`); return users; } catch (error) { this.logger.error("Error scanning DynamoDB:", error); @@ -487,7 +522,10 @@ export class UserService { } } - async getAllActiveUsers(): Promise { + // purpose statement: retrieves all active users (Admins and Employees) + // use case: for admin to see all current users on user management page (current users tab) +async getAllActiveUsers(): Promise { + this.logger.log('Starting getAllActiveUsers - Fetching all active users (Admin and Employee)'); const params = { TableName: process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE", FilterExpression: "#pos IN (:admin, :employee)", @@ -501,6 +539,7 @@ export class UserService { }; try { + this.logger.log('Executing DynamoDB scan with filter for Admin and Employee users...'); const result = await this.dynamoDb.scan(params).promise(); if (!result.Items) { this.logger.error("No active users found."); @@ -513,6 +552,7 @@ export class UserService { email: item.email, name: item.userId, // Keep name as name })); + this.logger.debug(`Fetched ${users.length} active users.`); return users; @@ -520,19 +560,25 @@ export class UserService { this.logger.error("Error scanning DynamoDB:", error); if (error instanceof NotFoundException) throw error; throw new InternalServerErrorException( - "Failed to retrieve inactive users." + "Failed to retrieve active users." ); } } - // sends email to user once account is approved, used in method above when a user + // purpose statement: sends email to user once account is approved, used in method above when a user // is added to the Employee or Admin group from Inactive async sendVerificationEmail(userEmail: string): Promise { + this.logger.log(`Starting sendVerificationEmail for email: ${userEmail}`); + + if (!userEmail || !this.isValidEmail(userEmail)) { + this.logger.error(`Invalid email address provided: ${userEmail}`); + throw new BadRequestException("Valid email address is required"); + } + // remove actual email and add to env later!! - const fromEmail = process.env.NOTIFICATION_EMAIL_SENDER || - 'c4cneu.bcan@gmail.com'; + const fromEmail = process.env.NOTIFICATION_EMAIL_SENDER || 'c4cneu.bcan@gmail.com'; const params: AWS.SES.SendEmailRequest = { Source: fromEmail, @@ -549,13 +595,20 @@ export class UserService { }; try { + this.logger.log(`Calling AWS SES to send email to ${userEmail}...`); const result = await this.ses.sendEmail(params).promise(); - this.logger.log(`Verification email sent to ${userEmail}`); + this.logger.log(`✅ Verification email sent successfully to ${userEmail}. MessageId: ${result.MessageId}`); return result; } catch (err: unknown) { this.logger.error('Error sending email: ', err); - const errMessage = (err instanceof Error) ? err.message : 'Generic'; - throw new Error(`Failed to send email: ${errMessage}`); + const errMessage = (err instanceof Error) ? err.message : 'Unknown error'; + throw new InternalServerErrorException(`Failed to send email: ${errMessage}`); } } + + // Helper method for email validation + private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } } diff --git a/frontend/src/Register.tsx b/frontend/src/Register.tsx index 58fe0a1..136be51 100644 --- a/frontend/src/Register.tsx +++ b/frontend/src/Register.tsx @@ -205,7 +205,7 @@ const Register = observer(() => {
- Don't have an account?{" "} + Have an account?{" "}