diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 4258bb17..d2127ce9 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -1,7 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AuthService } from '../auth.service'; -import { Logger, UnauthorizedException, InternalServerErrorException } from '@nestjs/common'; -import { describe, it, expect, beforeEach, vi, beforeAll } from 'vitest'; +import { Test, TestingModule } from "@nestjs/testing"; +import { AuthService } from "../auth.service"; +import { + Logger, + UnauthorizedException, + InternalServerErrorException, +} from "@nestjs/common"; +import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest"; // Create mock functions for Cognito operations const mockAdminCreateUser = vi.fn().mockReturnThis(); @@ -11,9 +15,7 @@ const mockGetUser = vi.fn().mockReturnThis(); const mockRespondToAuthChallenge = vi.fn().mockReturnThis(); const mockCognitoPromise = vi.fn(); // adminAddUserToGroup is called without .promise() in the service; return a resolved promise so `await` works -const mockAdminAddUserToGroup = vi.fn().mockResolvedValue({}); - - +const mockAdminAddUserToGroup = vi.fn().mockReturnThis() // Create mock functions for DynamoDB operations const mockDynamoGet = vi.fn().mockReturnThis(); const mockDynamoPut = vi.fn().mockReturnThis(); @@ -22,7 +24,7 @@ const mockDynamoScan = vi.fn().mockReturnThis(); const mockDynamoPromise = vi.fn(); // Mock AWS SDK -vi.mock('aws-sdk', () => ({ +vi.mock("aws-sdk", () => ({ default: { CognitoIdentityServiceProvider: vi.fn(() => ({ adminCreateUser: mockAdminCreateUser, @@ -39,22 +41,22 @@ vi.mock('aws-sdk', () => ({ put: mockDynamoPut, update: mockDynamoUpdate, promise: mockDynamoPromise, - scan: mockDynamoScan - })) - } - } + scan: mockDynamoScan, + })), + }, + }, })); -describe('AuthService', () => { +describe("AuthService", () => { let service: AuthService; // Set up environment variables for testing beforeAll(() => { - process.env.COGNITO_USER_POOL_ID = 'test-user-pool-id'; - process.env.COGNITO_CLIENT_ID = 'test-client-id'; - process.env.COGNITO_CLIENT_SECRET = 'test-client-secret'; - process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; - process.env.FISH_EYE_LENS = 'sha256'; + process.env.COGNITO_USER_POOL_ID = "test-user-pool-id"; + process.env.COGNITO_CLIENT_ID = "test-client-id"; + process.env.COGNITO_CLIENT_SECRET = "test-client-secret"; + process.env.DYNAMODB_USER_TABLE_NAME = "test-users-table"; + process.env.FISH_EYE_LENS = "sha256"; }); beforeEach(async () => { @@ -72,315 +74,334 @@ describe('AuthService', () => { mockDynamoPromise.mockResolvedValue({}); }); - describe('register', () => { - // 1. Set up mock responses for Cognito adminCreateUser and adminSetUserPassword + describe("register", () => { + // 1. Set up mock responses for Cognito adminCreateUser and adminSetUserPassword // 2. Set up mock response for DynamoDB put operation // 3. Call service.register with test data // 4. Assert that Cognito methods were called with correct parameters // 5. Assert that DynamoDB put was called with correct user data - it('should successfully register a user', async () => { + it("should successfully register a user", async () => { // Ensure scan returns no items (email not in use) mockDynamoPromise.mockResolvedValueOnce({ Items: [] }); + // Ensure get returns no items (username not in use) + mockDynamoPromise.mockResolvedValueOnce({}); - // Cognito promise chain (adminCreateUser().promise(), adminSetUserPassword().promise()) - mockCognitoPromise - .mockResolvedValueOnce({}) - .mockResolvedValueOnce({}); + // Cognito promise chain + // adminCreateUser().promise() + mockCognitoPromise.mockResolvedValueOnce({}); + // adminSetUserPassword().promise() + mockCognitoPromise.mockResolvedValueOnce({}); + // adminAddUserToGroup().promise() - needs to be mocked here + mockCognitoPromise.mockResolvedValueOnce({}); + // DynamoDB put().promise() + mockDynamoPromise.mockResolvedValueOnce({}); - await service.register('c4c', 'Pass123!', 'c4c@example.com'); + await service.register("c4c", "Pass123!", "c4c@example.com"); expect(mockAdminCreateUser).toHaveBeenCalledWith({ - UserPoolId: 'test-user-pool-id', - Username: 'c4c', + UserPoolId: "test-user-pool-id", + Username: "c4c", UserAttributes: [ - { Name: 'email', Value: 'c4c@example.com' }, - { Name: 'email_verified', Value: 'true' }, + { Name: "email", Value: "c4c@example.com" }, + { Name: "email_verified", Value: "true" }, ], - MessageAction: 'SUPPRESS', + MessageAction: "SUPPRESS", }); expect(mockAdminSetUserPassword).toHaveBeenCalledWith({ - UserPoolId: 'test-user-pool-id', - Username: 'c4c', - Password: 'Pass123!', + UserPoolId: "test-user-pool-id", + Username: "c4c", + Password: "Pass123!", Permanent: true, }); - // adminAddUserToGroup is called without .promise() so verify invocation expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: 'Inactive', - UserPoolId: 'test-user-pool-id', - Username: 'c4c', + GroupName: "Inactive", + UserPoolId: "test-user-pool-id", + Username: "c4c", }); expect(mockDynamoPut).toHaveBeenCalledWith( expect.objectContaining({ - TableName: 'test-users-table', - Item: expect.objectContaining({ userId: 'c4c', email: 'c4c@example.com' }), - }), + TableName: "test-users-table", + Item: expect.objectContaining({ + userId: "c4c", + email: "c4c@example.com", + position: "Inactive", + }), + }) ); }); - - it("should deny someone from making an email when it is already in use", async () => { - - }) + it("should deny someone from making an email when it is already in use", async () => {}); }); - describe('login', () => { - - // 1. Mock successful Cognito initiateAuth response with tokens - // 2. Mock Cognito getUser response with user attributes - // 3. Mock DynamoDB get to return existing user - // 4. Call service.login and verify returned token and user data - it('should successfully login existing user', async () => { + describe("login", () => { + // 1. Mock successful Cognito initiateAuth response with tokens + // 2. Mock Cognito getUser response with user attributes + // 3. Mock DynamoDB get to return existing user + // 4. Call service.login and verify returned token and user data + it("should successfully login existing user", async () => { // Mock Cognito initiateAuth const mockInitiateAuth = () => ({ - promise: () => Promise.resolve({ - AuthenticationResult: { - IdToken: 'id-token', - AccessToken: 'access-token', - }, - }), + promise: () => + Promise.resolve({ + AuthenticationResult: { + IdToken: "id-token", + AccessToken: "access-token", + }, + }), }); - + // Mock Cognito getUser const mockGetUser = () => ({ - promise: () => Promise.resolve({ - UserAttributes: [{ Name: 'email', Value: 'c4c@example.com' }], - }), + promise: () => + Promise.resolve({ + UserAttributes: [{ Name: "email", Value: "c4c@example.com" }], + }), }); - + // Mock DynamoDB get to return existing user const mockGet = () => ({ - promise: () => Promise.resolve({ - Item: { userId: 'c4c', email: 'c4c@example.com', biography: '' }, - }), + promise: () => + Promise.resolve({ + Item: { userId: "c4c", email: "c4c@example.com", biography: "" }, + }), }); - - (service['cognito'] as any).initiateAuth = mockInitiateAuth; - (service['cognito'] as any).getUser = mockGetUser; - (service['dynamoDb'] as any).get = mockGet; - + + (service["cognito"] as any).initiateAuth = mockInitiateAuth; + (service["cognito"] as any).getUser = mockGetUser; + (service["dynamoDb"] as any).get = mockGet; + // Call the login method - const result = await service.login('c4c', 'Pass123!'); - + const result = await service.login("c4c", "Pass123!"); + // Verify the results - expect(result.access_token).toBe('id-token'); + expect(result.access_token).toBe("id-token"); expect(result.user).toEqual({ - userId: 'c4c', - email: 'c4c@example.com', - biography: '', + userId: "c4c", + email: "c4c@example.com", + biography: "", }); - expect(result.message).toBe('Login Successful!'); + expect(result.message).toBe("Login Successful!"); }); - // 1. Mock Cognito initiateAuth to return NEW_PASSWORD_REQUIRED challenge - // 2. Call service.login and verify challenge response structure - it('should handle NEW_PASSWORD_REQUIRED challenge', async () => { - // Mock Cognito initiateAuth - const mockInitiateAuth = () => ({ - promise: () => Promise.resolve({ - ChallengeName: 'NEW_PASSWORD_REQUIRED', - Session: 'session-123', + // 1. Mock Cognito initiateAuth to return NEW_PASSWORD_REQUIRED challenge + // 2. Call service.login and verify challenge response structure + it("should handle NEW_PASSWORD_REQUIRED challenge", async () => { + // Mock Cognito initiateAuth + const mockInitiateAuth = () => ({ + promise: () => + Promise.resolve({ + ChallengeName: "NEW_PASSWORD_REQUIRED", + Session: "session-123", ChallengeParameters: { - requiredAttributes: '["email"]' - } - }) - }); - - // Replace the cognito method with mock - (service['cognito'] as any).initiateAuth = mockInitiateAuth; - - // Call login and expect challenge response - const result = await service.login('c4c', 'newPassword'); - - // Verify the challenge response - expect(result.challenge).toBe('NEW_PASSWORD_REQUIRED'); - expect(result.session).toBe('session-123'); - expect(result.requiredAttributes).toEqual(['email']); - expect(result.username).toBe('c4c'); - expect(result.access_token).toBeUndefined(); - expect(result.user).toEqual({}); + requiredAttributes: '["email"]', + }, + }), }); - it('should create new DynamoDB user if not exists', async () => { - const mockInitiateAuth = () => ({ - promise: () => Promise.resolve({ + // Replace the cognito method with mock + (service["cognito"] as any).initiateAuth = mockInitiateAuth; + + // Call login and expect challenge response + const result = await service.login("c4c", "newPassword"); + + // Verify the challenge response + expect(result.challenge).toBe("NEW_PASSWORD_REQUIRED"); + expect(result.session).toBe("session-123"); + expect(result.requiredAttributes).toEqual(["email"]); + expect(result.username).toBe("c4c"); + expect(result.access_token).toBeUndefined(); + expect(result.user).toEqual({}); + }); + + it("should create new DynamoDB user if not exists", async () => { + const mockInitiateAuth = () => ({ + promise: () => + Promise.resolve({ AuthenticationResult: { - IdToken: 'id-token', - AccessToken: 'access-token', + IdToken: "id-token", + AccessToken: "access-token", }, }), - }); - - // Mock getUser - const mockGetUser = () => ({ - promise: () => Promise.resolve({ - UserAttributes: [{ Name: 'email', Value: 'c4c@gmail.com' }], - }), - }); - - // Mock DynamoDB - const mockGet = () => ({ - promise: () => Promise.resolve({ + }); + // Mock getUser + const mockGetUser = () => ({ + promise: () => + Promise.resolve({ + UserAttributes: [{ Name: "email", Value: "c4c@gmail.com" }], }), - }); - - // Mock DynamoDB put - const mockPut = () => ({ - promise: () => Promise.resolve({}), - }); - - (service['cognito'] as any).initiateAuth = mockInitiateAuth; - (service['cognito'] as any).getUser = mockGetUser; - (service['dynamoDb'] as any).get = mockGet; - (service['dynamoDb'] as any).put = mockPut; - - const result = await service.login('c4c', 'Pass123!'); - - expect(result.access_token).toBe('id-token'); - expect(result.user).toEqual({ - userId: 'c4c', - email: 'c4c@gmail.com', - name: "", - position: "Inactive" - }); - expect(result.message).toBe('Login Successful!'); - - expect(result.user.userId).toBe('c4c'); - expect(result.user.email).toBe('c4c@gmail.com'); }); - // 1. Mock Cognito to throw NotAuthorizedException - // 2. Verify UnauthorizedException is thrown by service - it('should handle NotAuthorizedException', async () => { - const mockInitiateAuth = () => ({ - promise: () => Promise.reject({ - code: 'NotAuthorizedException', - message: 'Incorrect username or password', + // Mock DynamoDB + const mockGet = () => ({ + promise: () => Promise.resolve({}), + }); + + // Mock DynamoDB put + const mockPut = () => ({ + promise: () => Promise.resolve({}), + }); + + (service["cognito"] as any).initiateAuth = mockInitiateAuth; + (service["cognito"] as any).getUser = mockGetUser; + (service["dynamoDb"] as any).get = mockGet; + (service["dynamoDb"] as any).put = mockPut; + + const result = await service.login("c4c", "Pass123!"); + + expect(result.access_token).toBe("id-token"); + expect(result.user).toEqual({ + userId: "c4c", + email: "c4c@gmail.com", + position: "Inactive", + }); + expect(result.message).toBe("Login Successful!"); + + expect(result.user.userId).toBe("c4c"); + expect(result.user.email).toBe("c4c@gmail.com"); + }); + + // 1. Mock Cognito to throw NotAuthorizedException + // 2. Verify UnauthorizedException is thrown by service + it("should handle NotAuthorizedException", async () => { + const mockInitiateAuth = () => ({ + promise: () => + Promise.reject({ + code: "NotAuthorizedException", + message: "Incorrect username or password", }), - }); - - (service['cognito'] as any).initiateAuth = mockInitiateAuth; - await expect(service.login('c4c', 'wrongpassword')).rejects.toThrow( - UnauthorizedException - ); }); - // 1. Remove environment variables - // 2. Expect service to throw configuration error - it('should handle missing client credentials', async () => { + (service["cognito"] as any).initiateAuth = mockInitiateAuth; + await expect(service.login("c4c", "wrongpassword")).rejects.toThrow( + UnauthorizedException + ); + }); + + // 1. Remove environment variables + // 2. Expect service to throw configuration error + it("should handle missing client credentials", async () => { delete process.env.COGNITO_CLIENT_ID; delete process.env.COGNITO_CLIENT_SECRET; - await expect(service.login('john', '123')).rejects.toThrow( - 'Cognito Client ID or Secret is not defined.', + await expect(service.login("john", "123")).rejects.toThrow( + "Cognito Client ID or Secret is not defined." ); - process.env.COGNITO_CLIENT_ID = 'test-client-id'; - process.env.COGNITO_CLIENT_SECRET = 'test-client-secret'; + process.env.COGNITO_CLIENT_ID = "test-client-id"; + process.env.COGNITO_CLIENT_SECRET = "test-client-secret"; }); // 1. Mock Cognito to return response without required tokens // 2. Verify appropriate error is thrown - it('should handle missing tokens in response', async () => { + it("should handle missing tokens in response", async () => { mockCognitoPromise.mockResolvedValueOnce({ AuthenticationResult: {}, }); - await expect(service.login('c4c', 'c4c@gmail.com')).rejects.toThrow( - 'Authentication failed: Missing IdToken or AccessToken', + await expect(service.login("c4c", "c4c@gmail.com")).rejects.toThrow( + "Authentication failed: Missing IdToken or AccessToken" ); }); }); - describe('setNewPassword', () => { - + describe("setNewPassword", () => { // 1. Mock successful respondToAuthChallenge response // 2. Call service.setNewPassword with test data // 3. Verify correct parameters passed to Cognito // 4. Verify returned access token - it('should successfully set new password', async () => { + it("should successfully set new password", async () => { const mockRespondToAuthChallenge = () => ({ - promise: () => Promise.resolve({ - AuthenticationResult: { - IdToken: 'new-id-token' - } - }) + promise: () => + Promise.resolve({ + AuthenticationResult: { + IdToken: "new-id-token", + }, + }), }); - - (service['cognito'] as any).respondToAuthChallenge = mockRespondToAuthChallenge; + + (service["cognito"] as any).respondToAuthChallenge = + mockRespondToAuthChallenge; const result = await service.setNewPassword( - 'NewPass123!', - 'session123', - 'c4c', - 'c4c@gmail.com' + "NewPass123!", + "session123", + "c4c", + "c4c@gmail.com" ); - - expect(result.access_token).toBe('new-id-token'); + + expect(result.access_token).toBe("new-id-token"); }); - // 1. Mock Cognito to return response without IdToken + // 1. Mock Cognito to return response without IdToken // 2. Verify error is thrown - it('should handle failed password setting', async () => { + it("should handle failed password setting", async () => { mockCognitoPromise.mockResolvedValueOnce({ AuthenticationResult: {}, }); await expect( - service.setNewPassword('NewPass123!', 's123', 'c4c'), - ).rejects.toThrow('Failed to set new password'); + service.setNewPassword("NewPass123!", "s123", "c4c") + ).rejects.toThrow("Failed to set new password"); }); - // 1. Mock Cognito to throw error - // 2. Verify error handling - it('should handle Cognito errors', async () => { + // 1. Mock Cognito to throw error + // 2. Verify error handling + it("should handle Cognito errors", async () => { const mockRespondToAuthChallenge = () => ({ - promise: () => Promise.reject(new Error('Cognito Error')) + promise: () => Promise.reject(new Error("Cognito Error")), }); - - (service['cognito'] as any).respondToAuthChallenge = mockRespondToAuthChallenge; - + + (service["cognito"] as any).respondToAuthChallenge = + mockRespondToAuthChallenge; + await expect( - service.setNewPassword('NewPass123!', 's123', 'c4c') - ).rejects.toThrow('Cognito Error'); + service.setNewPassword("NewPass123!", "s123", "c4c") + ).rejects.toThrow("Cognito Error"); }); }); - describe('updateProfile', () => { - + describe("updateProfile", () => { // 1. Mock successful DynamoDB update // 2. Call service.updateProfile with test data // 3. Verify correct UpdateExpression and parameters - it('should successfully update user profile', async () => { + it("should successfully update user profile", async () => { mockDynamoPromise.mockResolvedValueOnce({}); - await service.updateProfile('c4c', 'c4c@example.com', 'Software Developer'); + await service.updateProfile( + "c4c", + "c4c@example.com", + "Software Developer" + ); expect(mockDynamoUpdate).toHaveBeenCalledWith( expect.objectContaining({ - Key: { userId: 'c4c' }, + Key: { userId: "c4c" }, UpdateExpression: - 'SET email = :email, position_or_role = :position_or_role', + "SET email = :email, position_or_role = :position_or_role", ExpressionAttributeValues: { - ':email': 'c4c@example.com', - ':position_or_role': 'Software Developer', + ":email": "c4c@example.com", + ":position_or_role": "Software Developer", }, - }), + }) ); }); // 1. Mock DynamoDB to throw error // 2. Verify error handling - it('should handle DynamoDB update errors', async () => { - mockDynamoPromise.mockRejectedValueOnce(new Error('DB error')); + it("should handle DynamoDB update errors", async () => { + const mockUpdate = vi.fn().mockReturnValue({ + promise: vi.fn().mockRejectedValue(new Error("DB error")) + }); + + (service['dynamoDb'] as any).update = mockUpdate; - await expect( - service.updateProfile('c4c', 'c4c@example.com', 'Manager'), - ).rejects.toThrow('DB error'); - }); + await expect( + service.updateProfile("c4c", "c4c@example.com", "Active") + ).rejects.toThrow("DB error"); +}); }); -}); \ No newline at end of file +}); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 17ec7998..77bc22b5 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,6 +1,7 @@ import { Controller, Post, Body } from '@nestjs/common'; import { AuthService } from './auth.service'; import { User } from '../types/User'; +import { UserStatus } from '../../../middle-layer/types/UserStatus'; @Controller('auth') export class AuthController { @@ -16,17 +17,6 @@ export class AuthController { return { message: 'User registered successfully' }; } - // Make sure to put a guard on this route - @Post('change-role') - async addToGroup( - @Body('username') username: string, - @Body('groupName') groupName: string, - @Body('requestedBy') requestedBy: string, - ): Promise<{ message: string }> { - await this.authService.addUserToGroup(username, groupName,requestedBy); - return { message: `User changed to ${groupName} successfully` }; - } - @Post('login') async login( @Body('username') username: string, @@ -66,9 +56,9 @@ export class AuthController { @Post('delete-user') async deleteUser( @Body('username') username: string, - ): Promise<{ message: string }> { - await this.authService.deleteUser(username); - return { message: `${username} has been deleted` }; + ): Promise { + let user = await this.authService.deleteUser(username); + return user; } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 580f1d5b..4e042a7c 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -37,127 +37,242 @@ export class AuthService { .digest("base64"); } - async register( - username: string, - password: string, - email: string - ): Promise { - const userPoolId = process.env.COGNITO_USER_POOL_ID; + async register( + username: string, + password: string, + email: string +): Promise { + const userPoolId = process.env.COGNITO_USER_POOL_ID; + const tableName = process.env.DYNAMODB_USER_TABLE_NAME; + + // Validate environment variables + if (!userPoolId) { + this.logger.error("Cognito User Pool ID is not defined in environment variables."); + throw new InternalServerErrorException("Server configuration error"); + } - if (!userPoolId) { - this.logger.error("Cognito User Pool ID is not defined."); - throw new Error("Cognito User Pool ID is not defined."); + if (!tableName) { + this.logger.error("DynamoDB User Table Name is not defined in environment variables."); + throw new InternalServerErrorException("Server configuration error"); + } + + // Validate input parameters + if (!username || username.trim().length === 0) { + throw new BadRequestException("Username is required"); + } + + if (!password || password.length < 8) { + throw new BadRequestException("Password must be at least 8 characters long"); + } + + if (!email || !this.isValidEmail(email)) { + throw new BadRequestException("Valid email address is required"); + } + + this.logger.log(`Starting registration for username: ${username}, email: ${email}`); + + try { + // Step 1: Check if email already exists in DynamoDB + this.logger.log(`Checking if email ${email} is already in use...`); + + const emailCheckParams = { + TableName: tableName, + FilterExpression: "#email = :email", + ExpressionAttributeNames: { + "#email": "email", + }, + ExpressionAttributeValues: { + ":email": email, + }, + }; + + const emailCheckResult = await this.dynamoDb.scan(emailCheckParams).promise(); + + if (emailCheckResult.Items && emailCheckResult.Items.length > 0) { + this.logger.warn(`Registration failed: Email ${email} already exists`); + throw new ConflictException("An account with this email already exists"); } + // Step 2: Check if username already exists in DynamoDB + this.logger.log(`Checking if username ${username} is already in use...`); + + const usernameCheckParams = { + TableName: tableName, + Key: { userId: username }, + }; + + const usernameCheckResult = await this.dynamoDb.get(usernameCheckParams).promise(); + + if (usernameCheckResult.Item) { + this.logger.warn(`Registration failed: Username ${username} already exists`); + throw new ConflictException("This username is already taken"); + } + + this.logger.log(`Email and username are unique. Proceeding with Cognito user creation...`); + + // Step 3: Create user in Cognito + let cognitoUserCreated = false; + try { - // Check to see if the inputted email already exists in the user table - this.logger.log(`Checking if email ${email} is already in use.`); - const paramEmailCheck = { - TableName: process.env.DYNAMODB_USER_TABLE_NAME as string, - FilterExpression: "#email = :email", - ExpressionAttributeNames: { - "#email": "email", - }, - ExpressionAttributeValues: { - ":email": email, - }, - }; - let uniqueEmailCheck = await this.dynamoDb - .scan(paramEmailCheck) - .promise(); + await this.cognito.adminCreateUser({ + UserPoolId: userPoolId, + Username: username, + UserAttributes: [ + { Name: "email", Value: email }, + { Name: "email_verified", Value: "true" }, + ], + MessageAction: "SUPPRESS", + }).promise(); + + cognitoUserCreated = true; + this.logger.log(`✓ Cognito user created successfully for ${username}`); + + } catch (cognitoError: any) { + this.logger.error(`Cognito user creation failed for ${username}:`, cognitoError); + + // Handle specific Cognito errors + if (cognitoError.code === 'UsernameExistsException') { + throw new ConflictException("Username already exists in authentication system"); + } else if (cognitoError.code === 'InvalidPasswordException') { + throw new BadRequestException("Password does not meet security requirements"); + } else if (cognitoError.code === 'InvalidParameterException') { + throw new BadRequestException(`Invalid registration parameters: ${cognitoError.message}`); + } else { + throw new InternalServerErrorException("Failed to create user account"); + } + } - if (uniqueEmailCheck.Items && uniqueEmailCheck.Items.length > 0) { - throw new ConflictException("Email already in use."); // 409 status + // Step 4: Set user password + try { + await this.cognito.adminSetUserPassword({ + UserPoolId: userPoolId, + Username: username, + Password: password, + Permanent: true, + }).promise(); + + this.logger.log(`✓ Password set successfully for ${username}`); + + } catch (passwordError: any) { + this.logger.error(`Failed to set password for ${username}:`, passwordError); + + // Rollback: Delete Cognito user if password setting fails + if (cognitoUserCreated) { + this.logger.warn(`Rolling back: Deleting Cognito user ${username}...`); + try { + await this.cognito.adminDeleteUser({ + UserPoolId: userPoolId, + Username: username, + }).promise(); + this.logger.log(`Rollback successful: Cognito user ${username} deleted`); + } catch (rollbackError) { + this.logger.error(`Rollback failed: Could not delete Cognito user ${username}`, rollbackError); + } } - this.logger.log(`Email ${email} is unique. Proceeding with registration.`); - let createUserRes = await this.cognito - .adminCreateUser({ - UserPoolId: userPoolId, - Username: username, - UserAttributes: [ - { Name: "email", Value: email }, - { Name: "email_verified", Value: "true" }, - ], - MessageAction: "SUPPRESS", - }) - .promise(); - this.logger.log(`Cognito user created:`); - await this.cognito - .adminSetUserPassword({ - UserPoolId: userPoolId, - Username: username, - Password: password, - Permanent: true, - }) - .promise(); - this.logger.log(`Password set for user ${username}.`); + + if (passwordError.code === 'InvalidPasswordException') { + throw new BadRequestException("Password does not meet requirements: must be at least 8 characters with uppercase, lowercase, and numbers"); + } + throw new InternalServerErrorException("Failed to set user password"); + } + + // Step 5: Add user to Inactive group + try { await this.cognito.adminAddUserToGroup({ GroupName: "Inactive", UserPoolId: userPoolId, Username: username, - }); - this.logger.log(`User ${username} added to Inactive group.`); - const tableName = process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE"; + }).promise(); - // Change this so it adds a user object - const user: User = { - userId: username, - position: UserStatus.Inactive, - email: email, - name: "", - }; + this.logger.log(`✓ User ${username} added to Inactive group`); - // Spread operator to add the user object - const params = { - TableName: tableName, - Item: { - ...user, - }, - }; + } catch (groupError: any) { + this.logger.error(`Failed to add ${username} to Inactive group:`, groupError); - await this.dynamoDb.put(params).promise(); - - this.logger.log( - `User ${username} registered successfully and added to DynamoDB.` - ); - } catch (error) { - if (error instanceof ConflictException) { - this.logger.error("Email already in user", error.stack); - throw error; + // Rollback: Delete Cognito user + this.logger.warn(`Rolling back: Deleting Cognito user ${username}...`); + try { + await this.cognito.adminDeleteUser({ + UserPoolId: userPoolId, + Username: username, + }).promise(); + this.logger.log(`Rollback successful: Cognito user ${username} deleted`); + } catch (rollbackError) { + this.logger.error(`Rollback failed: Could not delete Cognito user ${username}`, rollbackError); } - if (error instanceof Error) { - this.logger.error("Registration failed", error.stack); - throw new Error(error.message || "Registration failed"); + + if (groupError.code === 'ResourceNotFoundException') { + throw new InternalServerErrorException("User group 'Inactive' does not exist in the system"); } - throw new Error("An unknown error occurred during registration"); + throw new InternalServerErrorException("Failed to assign user group"); } - } - async addUserToGroup(username: string, groupName: string, requestedBy : string): Promise { - const userPoolId = process.env.COGNITO_USER_POOL_ID; - if ( - groupName !== "Employee" && - groupName !== "Admin" && - groupName !== "Inactive" - ) { - throw new Error( - "Invalid group name. Must be Employee, Admin, or Inactive." - ); - } + // Step 6: Save user to DynamoDB + const user: User = { + userId: username, + position: UserStatus.Inactive, + email: email, + }; + try { - await this.cognito.adminAddUserToGroup({ - GroupName: groupName, - UserPoolId: userPoolId || "POOL_FAILURE", - Username: username, - }); - } catch (error) { - if (error instanceof Error) { - this.logger.error("Registration failed", error.stack); - throw new Error(error.message || "Registration failed"); + await this.dynamoDb.put({ + TableName: tableName, + Item: user, + }).promise(); + + this.logger.log(`✓ User ${username} saved to DynamoDB successfully`); + + } catch (dynamoError: any) { + this.logger.error(`Failed to save ${username} to DynamoDB:`, dynamoError); + + // Rollback: Delete Cognito user + this.logger.warn(`Rolling back: Deleting Cognito user ${username}...`); + try { + await this.cognito.adminDeleteUser({ + UserPoolId: userPoolId, + Username: username, + }).promise(); + this.logger.log(`Rollback successful: Cognito user ${username} deleted`); + } catch (rollbackError) { + this.logger.error(`Rollback failed: Could not delete Cognito user ${username}`, rollbackError); + // Critical: User exists in Cognito but not in DynamoDB + this.logger.error(`CRITICAL: User ${username} exists in Cognito but not in DynamoDB - manual cleanup required`); } - throw new Error("An unknown error occurred during registration"); + + throw new InternalServerErrorException("Failed to save user data to database"); + } + + this.logger.log(`✅ Registration completed successfully for ${username}`); + + } catch (error) { + // Re-throw known HTTP exceptions + if (error instanceof HttpException) { + throw error; } + + // Handle unexpected errors + if (error instanceof Error) { + this.logger.error(`Unexpected error during registration for ${username}:`, error.stack); + throw new InternalServerErrorException( + `Registration failed: ${error.message}` + ); + } + + // Handle completely unknown errors + this.logger.error(`Unknown error during registration for ${username}:`, error); + throw new InternalServerErrorException( + "An unexpected error occurred during registration" + ); } +} + +// Helper method for email validation +private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + + // Overall, needs better undefined handling and optional adding async login( @@ -276,7 +391,6 @@ export class AuthService { userId: username, email: email, position: UserStatus.Inactive, - name: "", }; await this.dynamoDb @@ -411,7 +525,7 @@ export class AuthService { } } - async deleteUser(username: string): Promise { + async deleteUser(username: string): Promise { const userPoolId = process.env.COGNITO_USER_POOL_ID; const tableName = process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE"; @@ -428,18 +542,22 @@ export class AuthService { TableName: tableName, Key: { userId: username, // Your partition key - }, + }, ReturnValues: "ALL_OLD" }; - await this.dynamoDb.delete(params).promise(); + let result = await this.dynamoDb.delete(params).promise(); this.logger.log( `User ${username} deleted successfully from Cognito and DynamoDB.` ); + + return result.Attributes as User; } catch (error) { if (error instanceof Error) { this.logger.error("Deletion failed", error.stack); throw new Error(error.message || "Deletion failed"); } + throw new Error("An unknown error occurred"); + } } } diff --git a/backend/src/grant/grant.module.ts b/backend/src/grant/grant.module.ts index 7ba6d902..a4123c2f 100644 --- a/backend/src/grant/grant.module.ts +++ b/backend/src/grant/grant.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { GrantService } from './grant.service'; import { GrantController } from './grant.controller'; import { NotificationsModule } from '../notifications/notification.module'; - @Module({ controllers: [GrantController], providers: [GrantService], diff --git a/backend/src/user/__test__/user.service.spec.ts b/backend/src/user/__test__/user.service.spec.ts index d2ae3ea0..eb141b94 100644 --- a/backend/src/user/__test__/user.service.spec.ts +++ b/backend/src/user/__test__/user.service.spec.ts @@ -1,36 +1,61 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UserController } from '../user.controller'; import { UserService } from '../user.service'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import AWS from 'aws-sdk'; +import { describe, it, expect, beforeEach, vi, beforeAll } from 'vitest'; + +// Create mock functions at module level +const mockScan = vi.fn().mockReturnThis(); +const mockGet = vi.fn().mockReturnThis(); +const mockUpdate = vi.fn().mockReturnThis(); +const mockPut = vi.fn().mockReturnThis(); +const mockDelete = vi.fn().mockReturnThis(); +const mockPromise = vi.fn(); + +// Mock Cognito functions +const mockAdminAddUserToGroup = vi.fn().mockReturnThis(); +const mockAdminRemoveUserFromGroup = vi.fn().mockReturnThis(); +const mockAdminDeleteUser = vi.fn().mockReturnThis(); +const mockCognitoPromise = vi.fn(); // Mock AWS SDK -vi.mock('aws-sdk', async () => { - const mockDocumentClient = { - scan: vi.fn().mockReturnThis(), - get: vi.fn().mockReturnThis(), - promise: vi.fn(), - }; - - const mockDynamoDB = { - DocumentClient: vi.fn(() => mockDocumentClient) - }; - - return { - default: { - DynamoDB: mockDynamoDB +vi.mock('aws-sdk', () => ({ + default: { + CognitoIdentityServiceProvider: vi.fn(() => ({ + adminAddUserToGroup: mockAdminAddUserToGroup, + adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, + adminDeleteUser: mockAdminDeleteUser, + promise: mockCognitoPromise, + })), + DynamoDB: { + DocumentClient: vi.fn(() => ({ + scan: mockScan, + get: mockGet, + update: mockUpdate, + put: mockPut, + delete: mockDelete, + promise: mockPromise, + })) } - }; -}); + } +})); describe('UserController', () => { let controller: UserController; let userService: UserService; - let mockDynamoDb: any; + + beforeAll(() => { + // Set up environment variables + process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; + process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; + }); beforeEach(async () => { - // Get the mocked DynamoDB instance - mockDynamoDb = new AWS.DynamoDB.DocumentClient(); + // Clear all mocks before each test + vi.clearAllMocks(); + + // Reset promise mocks to default resolved state + mockPromise.mockResolvedValue({}); + mockCognitoPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ controllers: [UserController], @@ -43,31 +68,49 @@ describe('UserController', () => { it('should get all users', async () => { const mockUsers = [ - { userId: '1', email: 'user1@example.com' }, - { userId: '2', email: 'user2@example.com' } + { userId: '1', email: 'user1@example.com', position: 'Employee' }, + { userId: '2', email: 'user2@example.com', position: 'Admin' } ]; // Setup the mock response - mockDynamoDb.promise.mockResolvedValueOnce({ Items: mockUsers }); + mockPromise.mockResolvedValueOnce({ Items: mockUsers }); const result = await userService.getAllUsers(); + expect(result).toEqual(mockUsers); - expect(mockDynamoDb.scan).toHaveBeenCalledWith({ - TableName: expect.any(String) + expect(mockScan).toHaveBeenCalledWith({ + TableName: 'test-users-table' }); }); it('should get user by id', async () => { - const mockUser = { userId: '1', email: 'user1@example.com' }; + const mockUser = { userId: '1', email: 'user1@example.com', position: 'Employee' }; // Setup the mock response - mockDynamoDb.promise.mockResolvedValueOnce({ Item: mockUser }); + mockPromise.mockResolvedValueOnce({ Item: mockUser }); const result = await userService.getUserById('1'); + expect(result).toEqual(mockUser); - expect(mockDynamoDb.get).toHaveBeenCalledWith({ - TableName: expect.any(String), + expect(mockGet).toHaveBeenCalledWith({ + TableName: 'test-users-table', Key: { userId: '1' } }); }); + + it('should handle errors when getting all users', async () => { + // Mock an error + mockPromise.mockRejectedValueOnce(new Error('DynamoDB error')); + + await expect(userService.getAllUsers()).rejects.toThrow('Could not retrieve users.'); + expect(mockScan).toHaveBeenCalled(); + }); + + it('should handle errors when getting user by id', async () => { + // Mock an error + mockPromise.mockRejectedValueOnce(new Error('DynamoDB error')); + + await expect(userService.getUserById('1')).rejects.toThrow('Could not retrieve user.'); + expect(mockGet).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 6419cf59..e6cfec70 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param,Post, Body } from '@nestjs/common'; import { UserService } from './user.service'; import { User } from '../../../middle-layer/types/User'; +import { UserStatus } from '../../../middle-layer/types/UserStatus'; @Controller('user') export class UserController { @@ -22,6 +23,16 @@ export class UserController { console.log("Fetching all active users"); return await this.userService.getAllActiveUsers(); } + // Make sure to put a guard on this route + @Post('change-role') + async addToGroup( + @Body('user') user: User, + @Body('groupName') groupName: UserStatus, + @Body('requestedBy') requestedBy: User, + ): Promise< User > { + let newUser:User = await this.userService.addUserToGroup(user, groupName,requestedBy); + return newUser as User; + } @Get(':id') async getUserById(@Param('id') userId: string) { diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index bbe22e2c..d042d74c 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -3,6 +3,10 @@ import { Logger, InternalServerErrorException, NotFoundException, + UnauthorizedException, + BadRequestException, + ConflictException, + HttpException, } from "@nestjs/common"; import AWS from "aws-sdk"; import { User } from "../../../middle-layer/types/User"; @@ -13,6 +17,8 @@ import { UserStatus } from "../../../middle-layer/types/UserStatus"; */ @Injectable() export class UserService { + private cognito = new AWS.CognitoIdentityServiceProvider(); + private readonly logger = new Logger(UserService.name); private dynamoDb = new AWS.DynamoDB.DocumentClient(); @@ -28,6 +34,232 @@ export class UserService { throw new Error("Could not retrieve users."); } } + async addUserToGroup( + user: User, + groupName: UserStatus, + requestedBy: User + ): Promise { + const userPoolId = process.env.COGNITO_USER_POOL_ID; + const tableName = process.env.DYNAMODB_USER_TABLE_NAME; + const username = user.userId; + const previousGroup = user.position; // Store the old group for rollback + + // 1. Validate environment variables + if (!userPoolId) { + this.logger.error( + "Cognito User Pool ID is not defined in environment variables" + ); + throw new InternalServerErrorException("Server configuration error"); + } + + if (!tableName) { + this.logger.error( + "DynamoDB User Table Name is not defined in environment variables" + ); + throw new InternalServerErrorException("Server configuration error"); + } + + // 2. Authorization check + if (requestedBy.position !== UserStatus.Admin) { + this.logger.warn( + `Unauthorized access attempt: ${requestedBy.userId} tried to add ${username} to ${groupName}` + ); + throw new UnauthorizedException( + "Only administrators can modify user groups" + ); + } + + // 3. Validate group name is a valid UserStatus + const validStatuses = Object.values(UserStatus); + if (!validStatuses.includes(groupName)) { + throw new BadRequestException( + `Invalid group name. Must be one of: ${validStatuses.join(", ")}` + ); + } + + // 4. Check if user exists in DynamoDB first + try { + const userCheckParams = { + TableName: tableName, + Key: { userId: username }, + }; + + const existingUser = await this.dynamoDb.get(userCheckParams).promise(); + + if (!existingUser.Item) { + this.logger.warn(`User ${username} not found in database`); + throw new NotFoundException(`User '${username}' does not exist`); + } + + // 5. Check if user is already in the requested group + const currentUser = existingUser.Item as User; + if (currentUser.position === groupName) { + this.logger.log(`User ${username} is already in group ${groupName}`); + return currentUser; // No change needed + } + + // 6. Prevent self-demotion for admins + if ( + requestedBy.userId === username && + requestedBy.position === UserStatus.Admin && + groupName !== UserStatus.Admin + ) { + throw new BadRequestException( + "Administrators cannot demote themselves" + ); + } + } catch (error) { + // Re-throw known exceptions + if (error instanceof HttpException) { + throw error; + } + this.logger.error(`Error checking user existence: ${username}`, error); + throw new InternalServerErrorException("Failed to verify user existence"); + } + + try { + // 7. Remove user from old Cognito group + if (previousGroup) { + try { + await this.cognito + .adminRemoveUserFromGroup({ + GroupName: previousGroup as string, + UserPoolId: userPoolId, + Username: username, + }) + .promise(); + + this.logger.log( + `✓ User ${username} removed from Cognito group ${previousGroup}` + ); + } catch (removeError: any) { + // Log but don't fail if user wasn't in the old group + this.logger.warn( + `Could not remove ${username} from old group ${previousGroup}: ${removeError.message}` + ); + } + } + + // 8. Add user to new Cognito group + await this.cognito + .adminAddUserToGroup({ + GroupName: groupName as string, + UserPoolId: userPoolId, + Username: username, + }) + .promise(); + + this.logger.log(`✓ User ${username} added to Cognito group ${groupName}`); + } catch (cognitoError: any) { + this.logger.error( + `Failed to add ${username} to Cognito group ${groupName}:`, + cognitoError + ); + + // Handle specific Cognito errors + if (cognitoError.code === "UserNotFoundException") { + throw new NotFoundException( + `User '${username}' not found in authentication system` + ); + } else if (cognitoError.code === "ResourceNotFoundException") { + throw new InternalServerErrorException( + `Group '${groupName}' does not exist in the system` + ); + } else if (cognitoError.code === "InvalidParameterException") { + throw new BadRequestException( + `Invalid parameters: ${cognitoError.message}` + ); + } + + throw new InternalServerErrorException( + "Failed to update user group in authentication system" + ); + } + + try { + // 9. Update user's position in DynamoDB + const params = { + TableName: tableName, + Key: { userId: username }, + UpdateExpression: "SET #position = :position", + ExpressionAttributeNames: { + "#position": "position", // Add this to handle reserved keyword + }, + ExpressionAttributeValues: { + ":position": groupName as string, + }, + ReturnValues: "ALL_NEW" as const, + }; + + const result = await this.dynamoDb.update(params).promise(); + + if (!result.Attributes) { + throw new InternalServerErrorException( + "Failed to retrieve updated user data" + ); + } + + this.logger.log( + `✅ User ${username} successfully moved from ${previousGroup} to ${groupName} by ${requestedBy.userId}` + ); + + return result.Attributes as User; + } catch (dynamoError: any) { + this.logger.error( + `Failed to update ${username} in DynamoDB:`, + dynamoError + ); + + // Attempt rollback: revert Cognito group change + this.logger.warn( + `Attempting rollback: reverting Cognito group for ${username} back to ${previousGroup}...` + ); + + try { + // Remove from new group + await this.cognito + .adminRemoveUserFromGroup({ + GroupName: groupName as string, + UserPoolId: userPoolId, + Username: username, + }) + .promise(); + + // Add back to old group + if (previousGroup) { + await this.cognito + .adminAddUserToGroup({ + GroupName: previousGroup as string, + UserPoolId: userPoolId, + Username: username, + }) + .promise(); + + this.logger.log( + `✓ Rollback successful: User ${username} restored to group ${previousGroup}` + ); + } + } catch (rollbackError: any) { + this.logger.error( + `Rollback failed: Could not restore ${username} to group ${previousGroup}`, + rollbackError + ); + this.logger.error( + `CRITICAL: User ${username} group updated in Cognito to ${groupName} but not in DynamoDB - manual sync required` + ); + } + + if (dynamoError.code === "ConditionalCheckFailedException") { + throw new ConflictException( + "User data was modified by another process" + ); + } + + throw new InternalServerErrorException( + "Failed to update user data in database" + ); + } + } async getUserById(userId: string): Promise { const params = { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86e281e9..635d93e1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,8 @@ "react-router-dom": "^6.26.2", "react-transition-group": "^4.4.5", "recharts": "^3.2.1", - "satcheljs": "^4.3.1" + "satcheljs": "^4.3.1", + "tailwindcss": "^3.4.17" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -50,7 +51,6 @@ "globals": "^15.9.0", "jsdom": "^25.0.1", "postcss": "^8.5.3", - "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "typescript-eslint": "^8.0.1", "vite": "^5.4.8", @@ -83,7 +83,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3064,7 +3063,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -3082,7 +3080,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3095,7 +3092,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3108,14 +3104,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -3133,7 +3127,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -3149,7 +3142,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -3690,7 +3682,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -5977,14 +5968,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -5998,7 +5987,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -6257,7 +6245,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6517,7 +6504,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -6654,7 +6640,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -6679,7 +6664,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6981,7 +6965,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -7023,7 +7006,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -7379,7 +7361,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dir-glob": { @@ -7398,7 +7379,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -7466,7 +7446,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ee-first": { @@ -8228,7 +8207,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -8245,7 +8223,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -8469,7 +8446,6 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -8490,7 +8466,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -8509,7 +8484,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -8519,7 +8493,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -9177,7 +9150,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -9416,7 +9388,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isomorphic-ws": { @@ -9432,7 +9403,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -9622,7 +9592,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -9997,7 +9966,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -10104,7 +10072,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -10332,7 +10299,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10374,7 +10340,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -10510,7 +10475,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/param-case": { @@ -10633,7 +10597,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10670,7 +10633,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -10687,7 +10649,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { @@ -10771,7 +10732,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10781,7 +10741,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -10828,7 +10787,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -10846,7 +10804,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -10872,7 +10829,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -10908,7 +10864,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -10921,7 +10876,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -10947,7 +10901,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -11342,7 +11295,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -11366,7 +11318,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -11941,7 +11892,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -11954,7 +11904,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12221,7 +12170,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -12249,7 +12197,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -12384,7 +12331,6 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -12407,7 +12353,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -12495,7 +12440,6 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -12533,7 +12477,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -12543,7 +12486,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -12710,7 +12652,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/ts-log": { @@ -13774,7 +13715,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -13859,7 +13799,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", diff --git a/frontend/src/context/auth/authContext.tsx b/frontend/src/context/auth/authContext.tsx index 515fec04..affebea6 100644 --- a/frontend/src/context/auth/authContext.tsx +++ b/frontend/src/context/auth/authContext.tsx @@ -1,9 +1,11 @@ -import { useContext, createContext, ReactNode, useEffect } from 'react'; +import { useContext, createContext, ReactNode } from 'react'; import { getAppStore } from '../../external/bcanSatchel/store'; import { setAuthState, logoutUser } from '../../external/bcanSatchel/actions'; import { observer } from 'mobx-react-lite'; import { User } from '../../../../middle-layer/types/User'; import { api } from '../../api'; +import { fetchUsers } from '../../main-page/users/UserActions.ts'; + /** * Available authenticated user options @@ -11,7 +13,7 @@ import { api } from '../../api'; interface AuthContextProps { isAuthenticated: boolean; login: (username: string, password: string) => Promise; - register: (username: string, password: string, email: string) => Promise<{ state: boolean; message: String; }>; + register: (username: string, password: string, email: string) => Promise<{ state: boolean; message: string; }>; logout: () => void; user: User | null; } @@ -40,10 +42,10 @@ export const AuthProvider = observer(({ children }: { children: ReactNode }) => }); const data = await response.json(); - if (response.ok && data.user) { console.log("Login successful:", data.user); setAuthState(true, data.user, null); + await fetchUsers(); return true; } else { console.warn('Login failed:', data.message || 'Unknown error'); @@ -58,7 +60,7 @@ export const AuthProvider = observer(({ children }: { children: ReactNode }) => /** * Register a new user and automatically log them in */ - const register = async (username: string, password: string, email: string): Promise<{ state: boolean; message: String; }>=> { + const register = async (username: string, password: string, email: string): Promise<{ state: boolean; message: string; }>=> { try { const response = await api('/auth/register', { method: 'POST', @@ -101,12 +103,12 @@ export const AuthProvider = observer(({ children }: { children: ReactNode }) => }; /** Restore user session on refresh */ - useEffect(() => { - api('/auth/session') - .then(r => (r.ok ? r.json() : Promise.reject())) - .then(({ user }) => setAuthState(true, user, null)) - .catch(() => logoutUser()); - }, []); + // useEffect(() => { + // api('/auth/session') + // .then(r => (r.ok ? r.json() : Promise.reject())) + // .then(({ user }) => setAuthState(true, user, null)) + // .catch(() => logoutUser()); + // }, []); return ( ({ isAuthenticated, user, @@ -16,60 +16,62 @@ export const setAuthState = action( }) ); +export const setActiveUsers = action("setActiveUsers", (users: User[]) => ({ + users, +})); +export const setInactiveUsers = action("setInactiveUsers", (users: User[]) => ({ + users, +})); + /** * Update user's profile data (email, biography, etc.). */ -export const updateUserProfile = action('updateUserProfile', (user: User) => ({ +export const updateUserProfile = action("updateUserProfile", (user: User) => ({ user, })); /** * Completely log out the user (clear tokens, user data, etc.). */ -export const logoutUser = action('logoutUser'); +export const logoutUser = action("logoutUser"); /** * Moves along the all grants that are fetched from back end to mutator. */ -export const fetchAllGrants = action( - 'fetchAllGrants', - (grants: Grant[]) => ({grants}) -); +export const fetchAllGrants = action("fetchAllGrants", (grants: Grant[]) => ({ + grants, +})); -export const updateFilter = action ( - 'updateFilter', - (status: Status | null) => ({status}) -) +export const updateFilter = action("updateFilter", (status: Status | null) => ({ + status, +})); -export const updateStartDateFilter = action ( - 'updateStartDateFilter', - (startDateFilter: Date | null) => ({startDateFilter}) -) -export const updateEndDateFilter = action ( - 'updateEndDateFilter', - (endDateFilter: Date | null) => ({endDateFilter}) -) -export const updateYearFilter = action ( - 'updateYearFilter', - (yearFilter: number[] | []) => ({yearFilter}) -) +export const updateStartDateFilter = action( + "updateStartDateFilter", + (startDateFilter: Date | null) => ({ startDateFilter }) +); +export const updateEndDateFilter = action( + "updateEndDateFilter", + (endDateFilter: Date | null) => ({ endDateFilter }) +); +export const updateYearFilter = action( + "updateYearFilter", + (yearFilter: number[] | []) => ({ yearFilter }) +); /** * Append a new grant to the current list of grants. */ -export const appendGrant = action( - 'appendGrant', - (grant: Grant) => ({ grant }) -); +export const appendGrant = action("appendGrant", (grant: Grant) => ({ grant })); export const updateSearchQuery = action( - 'updateSearchQuery', - (searchQuery: string) => ({searchQuery}) -) + "updateSearchQuery", + (searchQuery: string) => ({ searchQuery }) +); export const setNotifications = action( - 'setNotifications', - (notifications: {id: number; title: string; message: string }[]) => ({ + "setNotifications", + (notifications: { id: number; title: string; message: string }[]) => ({ notifications, }) -) +); diff --git a/frontend/src/external/bcanSatchel/mutators.ts b/frontend/src/external/bcanSatchel/mutators.ts index fd98e501..c3167933 100644 --- a/frontend/src/external/bcanSatchel/mutators.ts +++ b/frontend/src/external/bcanSatchel/mutators.ts @@ -10,16 +10,39 @@ import { updateYearFilter, setNotifications } from './actions'; -import { getAppStore } from './store'; +import { getAppStore, persistToSessionStorage } from './store'; +import { setActiveUsers, setInactiveUsers } from './actions'; + +/** + * setActiveUsers mutator +*/ +mutator(setActiveUsers, (actionMessage) => { + const store = getAppStore(); + store.activeUsers = actionMessage.users; + persistToSessionStorage(); +}); + +/** + * setInactiveUsers mutator +*/ +mutator(setInactiveUsers, (actionMessage) => { + const store = getAppStore(); + store.inactiveUsers = actionMessage.users; + persistToSessionStorage(); +}); /** * setAuthState mutator */ mutator(setAuthState, (actionMessage) => { + console.log('=== setAuthState MUTATOR CALLED ==='); const store = getAppStore(); + console.log('Setting user:', actionMessage.user); store.isAuthenticated = actionMessage.isAuthenticated; store.user = actionMessage.user; store.accessToken = actionMessage.accessToken; + console.log('Calling persistToSessionStorage...'); + persistToSessionStorage(); }); /** @@ -32,6 +55,7 @@ mutator(updateUserProfile, (actionMessage) => { ...store.user, ...actionMessage.user, }; + persistToSessionStorage(); } }); @@ -43,6 +67,7 @@ mutator(logoutUser, () => { store.isAuthenticated = false; store.user = null; store.accessToken = null; + sessionStorage.removeItem('bcanAppStore'); }); diff --git a/frontend/src/external/bcanSatchel/store.ts b/frontend/src/external/bcanSatchel/store.ts index ed0b13fe..4327fb4d 100644 --- a/frontend/src/external/bcanSatchel/store.ts +++ b/frontend/src/external/bcanSatchel/store.ts @@ -36,11 +36,62 @@ const initialState: AppState = { }; -const store = createStore('appStore', initialState); +/** + * Hydrate store from sessionStorage + */ +function hydrateFromSessionStorage(): AppState { + try { + const saved = sessionStorage.getItem('bcanAppStore'); + console.log('Hydrating from sessionStorage:', saved); // Debug log + if (saved) { + const data = JSON.parse(saved); + return { + ...initialState, + isAuthenticated: data.isAuthenticated ?? false, + user: data.user ?? null, + accessToken: data.accessToken ?? null, + activeUsers: data.activeUsers ?? [], + inactiveUsers: data.inactiveUsers ?? [], + }; + } + } catch (error) { + console.error('Error hydrating store from sessionStorage:', error); + } + return initialState; +} + +const store = createStore('appStore', hydrateFromSessionStorage()); + +/** + * Persist store to sessionStorage + */ +export function persistToSessionStorage() { + try { + const state = store(); + console.log('=== PERSIST START ==='); + console.log('Current state:', state); + const dataToSave = { + isAuthenticated: state.isAuthenticated, + user: state.user ? JSON.parse(JSON.stringify(state.user)) : null, + accessToken: state.accessToken, + activeUsers: state.activeUsers ? state.activeUsers.map(u => JSON.parse(JSON.stringify(u))) : [], + inactiveUsers: state.inactiveUsers ? state.inactiveUsers.map(u => JSON.parse(JSON.stringify(u))) : [], + }; + console.log('Data to save:', dataToSave); + sessionStorage.setItem('bcanAppStore', JSON.stringify(dataToSave)); + console.log('Successfully saved to sessionStorage'); + console.log('Verification - retrieved from storage:', sessionStorage.getItem('bcanAppStore')); + console.log('=== PERSIST END ==='); + } catch (error) { + console.error('Error persisting store to sessionStorage:', error); + } +} /** * Getter function for the store */ export function getAppStore() { - return store(); + const state = store(); + console.log('Current store.user:', state.user); // Debug: log current user when accessed + return state; } diff --git a/frontend/src/main-page/users/PendingUserCard.tsx b/frontend/src/main-page/users/PendingUserCard.tsx index e760ace9..8dd9c1a4 100644 --- a/frontend/src/main-page/users/PendingUserCard.tsx +++ b/frontend/src/main-page/users/PendingUserCard.tsx @@ -1,12 +1,68 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import UserPositionCard from "./UserPositionCard"; import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; +import { api } from "../../api" +import { UserStatus } from "../../../../middle-layer/types/UserStatus"; +import { getAppStore } from "../../external/bcanSatchel/store"; +import { User } from "../../../../middle-layer/types/User"; +import { toJS } from "mobx"; +import { moveUserToActive } from "./UserActions"; interface PendingUserCardProps { name: string; email: string; position: string; } +const approveInactiveUser = async (user: User) => { + const store = getAppStore(); + console.log("Approving user:", user); + console.log("requested user", store.user); + try { + const body = JSON.stringify({ + user: user as User, + groupName: "Employee" as UserStatus, + requestedBy: toJS(store.user) + }) + console.log("Request body:", body); + const response = await api("/user/change-role", { + method: 'POST', + headers: { + 'Content-Type': 'application/json' // ← Add this! + }, body: body + }); + if (!response.ok) { + throw new Error(`HTTP Error, Status: ${response.status}`); + } + + const updatedUser = await response.json(); + moveUserToActive(updatedUser); + } + catch (error) { + console.error("Error activating user:", error); + } +} + + +const deleteUser = async (username: string) => { + const store = getAppStore(); + try { + const response = await api("/auth/delete-user", { + method: 'POST', body: JSON.stringify({ + username: username, + }) + }); + if (!response.ok) { + throw new Error(`HTTP Error, Status: ${response.status}`); + } + + const updatedUser = await response.json(); + store.inactiveUsers = store.inactiveUsers.filter((user) => user.userId !== updatedUser.userId); + } + catch (error) { + console.error("Error activating user:", error); + } +} + const PendingUserCard = ({ name, @@ -22,10 +78,10 @@ const PendingUserCard = ({
- -
diff --git a/frontend/src/main-page/users/UserActions.ts b/frontend/src/main-page/users/UserActions.ts new file mode 100644 index 00000000..48160d83 --- /dev/null +++ b/frontend/src/main-page/users/UserActions.ts @@ -0,0 +1,58 @@ +import { api } from "../../api" +import { User } from "../../../../middle-layer/types/User"; +import { setActiveUsers, setInactiveUsers } from "../../external/bcanSatchel/actions"; +import { getAppStore } from "../../external/bcanSatchel/store"; +import { toJS } from "mobx"; +export const fetchActiveUsers = async (): Promise => { + try { + const response = await api("/user/active", { + method: 'GET' + }); + + if (!response.ok && response.status !== 200) { + throw new Error(`HTTP Error, Status: ${response.status}`); + } + + const activeUsers = await response.json(); + return activeUsers as User[]; + } catch (error) { + console.error("Error fetching active users:", error); + return []; // Return empty array on error + } +} + +export const fetchInactiveUsers = async (): Promise => { + try { + const response = await api("/user/inactive", { method: 'GET' }); + if (!response.ok && response.status !== 200) { + throw new Error(`HTTP Error, Status: ${response.status}`); + } + const inactiveUsers = await response.json(); + return inactiveUsers as User[]; + } + catch (error) { + console.error("Error fetching active users:", error); + return []; // Return empty array on error + + } +} + + +export const fetchUsers = async () => { + console.log("Fetching users..."); + const active = await fetchActiveUsers(); + const inactive = await fetchInactiveUsers(); + if (active) { + setActiveUsers(active); + console.log("Active users fetched:", toJS(getAppStore().activeUsers)); + } + if (inactive) { + setInactiveUsers(inactive); + console.log("Inactive users fetched:", toJS(getAppStore().inactiveUsers)); + } + }; + +export const moveUserToActive = (user: User) => { + setActiveUsers([...getAppStore().activeUsers, user]); + setInactiveUsers(getAppStore().inactiveUsers.filter(u => u.userId !== user.userId)); +} \ No newline at end of file diff --git a/frontend/src/main-page/users/UserPositionCard.tsx b/frontend/src/main-page/users/UserPositionCard.tsx index 6f15951e..9cb5afc0 100644 --- a/frontend/src/main-page/users/UserPositionCard.tsx +++ b/frontend/src/main-page/users/UserPositionCard.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { UserStatus } from "../../../../middle-layer/types/UserStatus"; interface UserPositionCardProps { position: string; @@ -7,13 +8,13 @@ interface UserPositionCardProps { const UserPositionCard = ({ position }: UserPositionCardProps) => { const cardStyles = useMemo(() => { switch (position.toLowerCase()) { - case "admin": + case "Admin" as UserStatus: return "bg-[#BCFFD8] border-[#119548] text-[#119548]"; - case "employee": + case "Employee" as UserStatus: return "bg-[#FFF8CA] border-[#F8CC16] text-[#8a710c]"; case "deactive": return "bg-[#FFB0B0] border-[#DF0404] text-[#DF0404]"; - case "inactive": + case "Inactive" as UserStatus: default: return "bg-[#D3D3D3] border-[#666666] text-[#666666]"; } diff --git a/frontend/src/main-page/users/Users.tsx b/frontend/src/main-page/users/Users.tsx index 31e344f8..13fa0e1c 100644 --- a/frontend/src/main-page/users/Users.tsx +++ b/frontend/src/main-page/users/Users.tsx @@ -1,70 +1,31 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import ApprovedUserCard from "./ApprovedUserCard"; import PendingUserCard from "./PendingUserCard"; -import { User } from "../../../../middle-layer/types/User"; import { Pagination, ButtonGroup, IconButton } from "@chakra-ui/react"; import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; import { observer } from "mobx-react-lite"; import { getAppStore } from "../../external/bcanSatchel/store"; +// import { fetchUsers } from "./UserActions"; // Represents a specific tab to show on the user page enum UsersTab { PendingUsers, CurrentUsers, } -import { api } from "../../api" -const fetchActiveUsers = async (): Promise => { - try { - const response = await api("/user/active", { - method: 'GET' - }); - - if (!response.ok) { - throw new Error(`HTTP Error, Status: ${response.status}`); - } - - const activeUsers = await response.json(); - return activeUsers as User[]; - } catch (error) { - console.error("Error fetching active users:", error); - return []; // Return empty array on error - } -} -const fetchInactiveUsers = async () => { - try { - const response = await api("/user/inactive", {method : 'GET' }); - if (!response.ok) { - throw new Error(`HTTP Error, Status: ${response.status}`); - } - const inactiveUsers = await response.json(); - return inactiveUsers as User[]; - } - catch (error) { - console.error("Error fetching active users:", error); - } -} const ITEMS_PER_PAGE = 8; + const Users = observer(() => { - const store = getAppStore(); - - - useEffect(() => { - const fetchUsers = async () => { - const active = await fetchActiveUsers(); - const inactive = await fetchInactiveUsers(); - if (active) { - store.activeUsers = active; - } - if (inactive) { -store.inactiveUsers = inactive; } - }; - fetchUsers(); - - }, []); +const store = getAppStore(); + +// useEffect(() => { +// fetchUsers() +// }, []); + + const [usersTabStatus, setUsersTabStatus] = useState( UsersTab.CurrentUsers ); @@ -75,7 +36,7 @@ store.inactiveUsers = inactive; } ? store.inactiveUsers : store.activeUsers; - const numInactiveUsers = mockUsers.filter((user) => user.position === "Inactive").length; + const numInactiveUsers = store.inactiveUsers.length; const numUsers = filteredUsers.length; const pageStartIndex = (currentPage - 1) * ITEMS_PER_PAGE; const pageEndIndex = @@ -97,11 +58,10 @@ store.inactiveUsers = inactive; }
{currentPageUsers.map((user) => ( + name={user.userId} + email={user.email} + position={user.position} + /> ))} )}