diff --git a/apps/backend/lambdas/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index 8fa37d62..fa564866 100644 --- a/apps/backend/lambdas/auth/handler.ts +++ b/apps/backend/lambdas/auth/handler.ts @@ -113,13 +113,13 @@ export const handler = async (event: any): Promise => { // >>> ROUTES-START (do not remove this marker) // CLI-generated routes will be inserted here - + // POST /register if (normalizedPath === '/register' && method === 'POST') { return await handleRegister(event); } - + // POST /login if (normalizedPath === '/login' && method === 'POST') { return await handleLogin(event); @@ -200,7 +200,7 @@ export const handler = async (event: any): Promise => { return json(500, { message: 'Failed to resend verification code' }); } } - + // POST /logout if (normalizedPath === '/logout' && method === 'POST') { const authHeader = event.headers?.authorization || event.headers?.Authorization; @@ -209,8 +209,8 @@ export const handler = async (event: any): Promise => { } // Extract token (remove "Bearer " prefix if present) - const accessToken = authHeader.startsWith('Bearer ') - ? authHeader.slice(7) + const accessToken = authHeader.startsWith('Bearer ') + ? authHeader.slice(7) : authHeader; if (!accessToken) { @@ -234,7 +234,7 @@ export const handler = async (event: any): Promise => { return json(500, { message: 'Failed to logout' }); } } - + // POST /forgot-password if (normalizedPath === '/forgot-password' && method === 'POST') { const body = event.body ? JSON.parse(event.body) as Record : {}; @@ -270,7 +270,7 @@ export const handler = async (event: any): Promise => { return json(500, { message: 'Failed to initiate password reset' }); } } - + // POST /reset-password if (normalizedPath === '/reset-password' && method === 'POST') { const body = event.body ? JSON.parse(event.body) as Record : {}; @@ -309,7 +309,7 @@ export const handler = async (event: any): Promise => { return json(500, { message: 'Failed to reset password' }); } } - // <<< ROUTES-END + // <<< ROUTES-END return json(404, { message: 'Not Found', path: normalizedPath, method }); } catch (err) { diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index 1e5ff6c9..8f464447 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -1,8 +1,18 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { + CognitoIdentityProviderClient, + AdminCreateUserCommand, + AdminDeleteUserCommand, +} from '@aws-sdk/client-cognito-identity-provider'; import db from './db' import { authenticateRequest, checkAuthorization, AuthContext } from './auth'; import { UserValidationUtils } from './validation-utils'; +const cognitoClient = new CognitoIdentityProviderClient({ + region: process.env.AWS_REGION || 'us-east-2', +}); +const USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; + function requireAuth(authContext: AuthContext, level: Parameters[1], resourceUserId?: number | string): APIGatewayProxyResult | undefined { const authCheck = checkAuthorization(authContext, level, resourceUserId); if (!authCheck.allowed) { @@ -226,7 +236,7 @@ export const handler = async (event: any): Promise => { const isAdmin = isAdminResult.value as boolean; const profile_image = profileImageResult.value ?? undefined; - // Check if user with this email already exists + // Check if user with this email already exists in DB const existingUser = await db .selectFrom('branch.users') .where('email', '=', email) @@ -236,15 +246,49 @@ export const handler = async (event: any): Promise => { if (existingUser) { return json(409, { message: 'User with this email already exists' }); } - - // insert new user (user_id auto-increments) + + // Create user in Cognito via AdminCreateUser — sends invite email with temp password + let cognitoSub: string; + try { + const cognitoResponse = await cognitoClient.send(new AdminCreateUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email, + DesiredDeliveryMediums: ['EMAIL'], + UserAttributes: [ + { Name: 'email', Value: email }, + { Name: 'email_verified', Value: 'true' }, + { Name: 'name', Value: name }, + ], + })); + const sub = cognitoResponse.User?.Attributes?.find(a => a.Name === 'sub')?.Value; + if (!sub) throw new Error('No sub returned from AdminCreateUser'); + cognitoSub = sub; + } catch (err: any) { + console.error('Cognito AdminCreateUser error:', err); + if (err.name === 'UsernameExistsException') { + return json(409, { message: 'User with this email already exists' }); + } + return json(500, { message: 'Failed to create user in authentication service' }); + } + + // Insert into database with cognito_sub try { await db .insertInto('branch.users') - .values({ email, name, is_admin: isAdmin, profile_image }) + .values({ cognito_sub: cognitoSub, email, name, is_admin: isAdmin, profile_image }) .execute(); - } catch (err) { + } catch (err: any) { console.error('Database insert error:', err); + // Rollback: delete Cognito user to keep systems in sync + try { + await cognitoClient.send(new AdminDeleteUserCommand({ + UserPoolId: USER_POOL_ID, + Username: email, + })); + console.log('Rolled back Cognito user after database failure'); + } catch (rollbackErr) { + console.error('Failed to rollback Cognito user:', rollbackErr); + } return json(500, { message: 'Failed to create user' }); } diff --git a/apps/backend/lambdas/users/package-lock.json b/apps/backend/lambdas/users/package-lock.json index 6efa5120..d3501a4a 100644 --- a/apps/backend/lambdas/users/package-lock.json +++ b/apps/backend/lambdas/users/package-lock.json @@ -8,6 +8,7 @@ "name": "lambda-local", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.978.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", @@ -35,7 +36,11 @@ "aws-jwt-verify": "^5.1.1" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/jest": "^30.0.0", "@types/node": "^20.11.30", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", "typescript": "^5.4.5" } }, @@ -44,6 +49,262 @@ "version": "1.0.0", "dev": true }, + "node_modules/@aws-sdk/client-cognito-identity-provider": { + "version": "3.1101.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1101.0.tgz", + "integrity": "sha512-iINP4bJzVeCN1UDDQ5aRIEaGOCUUf8esOkHp7P7q/mBB+X8icJUitDsCcf4F1poymUO5YK4ArARGF45dH74rSg==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/credential-provider-node": "^3.972.76", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.4.tgz", + "integrity": "sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.65.tgz", + "integrity": "sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.67.tgz", + "integrity": "sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.10.tgz", + "integrity": "sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/credential-provider-env": "^3.972.65", + "@aws-sdk/credential-provider-http": "^3.972.67", + "@aws-sdk/credential-provider-login": "^3.972.72", + "@aws-sdk/credential-provider-process": "^3.972.65", + "@aws-sdk/credential-provider-sso": "^3.973.9", + "@aws-sdk/credential-provider-web-identity": "^3.972.71", + "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.72.tgz", + "integrity": "sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.76.tgz", + "integrity": "sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.65", + "@aws-sdk/credential-provider-http": "^3.972.67", + "@aws-sdk/credential-provider-ini": "^3.973.10", + "@aws-sdk/credential-provider-process": "^3.972.65", + "@aws-sdk/credential-provider-sso": "^3.973.9", + "@aws-sdk/credential-provider-web-identity": "^3.972.71", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.65.tgz", + "integrity": "sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.9.tgz", + "integrity": "sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/token-providers": "3.1100.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.71.tgz", + "integrity": "sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.39.tgz", + "integrity": "sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1100.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1100.0.tgz", + "integrity": "sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==", + "dependencies": { + "@aws-sdk/core": "^3.977.4", + "@aws-sdk/nested-clients": "^3.997.39", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1627,6 +1888,81 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -2335,6 +2671,11 @@ "dev": true, "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -5624,7 +5965,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-detect": { diff --git a/apps/backend/lambdas/users/package.json b/apps/backend/lambdas/users/package.json index 72a66e8e..35345665 100644 --- a/apps/backend/lambdas/users/package.json +++ b/apps/backend/lambdas/users/package.json @@ -24,6 +24,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "@aws-sdk/client-cognito-identity-provider": "^3.978.0", "@branch/lambda-auth": "file:../../../../shared/lambda-auth", "aws-jwt-verify": "^5.1.1", "kysely": "^0.28.8", diff --git a/apps/backend/lambdas/users/test/user.unit.test.ts b/apps/backend/lambdas/users/test/user.unit.test.ts index 56afd973..661a0d29 100644 --- a/apps/backend/lambdas/users/test/user.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.unit.test.ts @@ -4,6 +4,16 @@ import { describe, test, expect, beforeEach, jest } from '@jest/globals'; jest.mock('../db'); jest.mock('../auth'); +jest.mock('@aws-sdk/client-cognito-identity-provider', () => ({ + CognitoIdentityProviderClient: jest.fn().mockImplementation(() => ({ + send: jest.fn().mockImplementation(async () => ({ + User: { Attributes: [{ Name: 'sub', Value: 'test-cognito-sub-123' }] }, + })), + })), + AdminCreateUserCommand: jest.fn().mockImplementation((args: unknown) => args), + AdminDeleteUserCommand: jest.fn().mockImplementation((args: unknown) => args), +})); + import { handler } from '../handler'; import db from '../db'; import { authenticateRequest, checkAuthorization } from '../auth'; diff --git a/apps/backend/lambdas/users/test/users.test.ts b/apps/backend/lambdas/users/test/users.test.ts index e7f34a0b..163c885a 100644 --- a/apps/backend/lambdas/users/test/users.test.ts +++ b/apps/backend/lambdas/users/test/users.test.ts @@ -6,6 +6,16 @@ import { authenticateRequest, checkAuthorization } from '../auth'; jest.mock('../auth'); +jest.mock('@aws-sdk/client-cognito-identity-provider', () => ({ + CognitoIdentityProviderClient: jest.fn().mockImplementation(() => ({ + send: jest.fn().mockResolvedValue({ + User: { Attributes: [{ Name: 'sub', Value: 'test-cognito-sub-123' }] }, + }), + })), + AdminCreateUserCommand: jest.fn().mockImplementation((args) => args), + AdminDeleteUserCommand: jest.fn().mockImplementation((args) => args), +})); + const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; const mockCheckAuthorization = checkAuthorization as jest.MockedFunction; diff --git a/apps/frontend/src/app/accounts/page.tsx b/apps/frontend/src/app/accounts/page.tsx index 1c5cd0f4..660ed95c 100644 --- a/apps/frontend/src/app/accounts/page.tsx +++ b/apps/frontend/src/app/accounts/page.tsx @@ -1,8 +1,11 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import StaffCard from '../components/StaffCard'; +import AddUserModal from '../components/AddUserModal'; +import { Button } from '@chakra-ui/react'; import { User } from '@/types'; +import { getAccessToken } from '@/lib/authTokens'; const mockUsers: User[] = [ { user_id: 1, name: 'Mehana Nagarur', email: 'nagarur.m@northeastern.edu', is_admin: true }, @@ -28,9 +31,20 @@ export const teamMembers = mockUsers.filter(u => !u.is_admin); export default function AccountsPage() { + const [isModalOpen, setIsModalOpen] = useState(false); + return (
-

Accounts

+
+

Accounts

+ +

Core BRANCH Facilitation Team

{facilitationTeam.map(user => ( @@ -43,6 +57,12 @@ export default function AccountsPage() { ))}
+ setIsModalOpen(false)} + onSuccess={() => setIsModalOpen(false)} + token={getAccessToken() ?? ''} + />
); -} \ No newline at end of file +} diff --git a/apps/frontend/src/app/components/AddUserModal.tsx b/apps/frontend/src/app/components/AddUserModal.tsx new file mode 100644 index 00000000..721caa70 --- /dev/null +++ b/apps/frontend/src/app/components/AddUserModal.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useState } from 'react'; +import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; +import TextInputField from './TextInputField'; +import { apiFetch } from '@/lib/api'; + +interface AddUserModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; + token: string; +} + +export default function AddUserModal({ open, onClose, onSuccess, token }: AddUserModalProps) { + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [isAdmin, setIsAdmin] = useState(false); + const [emailError, setEmailError] = useState(''); + const [nameError, setNameError] = useState(''); + const [submitError, setSubmitError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + function resetForm() { + setEmail(''); + setName(''); + setIsAdmin(false); + setEmailError(''); + setNameError(''); + setSubmitError(null); + } + + function handleClose() { + resetForm(); + onClose(); + } + + async function handleSubmit() { + const hasEmailError = !email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + const hasNameError = !name.trim() || name.trim().length < 2; + + setEmailError(hasEmailError ? 'Please enter a valid email address' : ''); + setNameError(hasNameError ? 'Name must be at least 2 characters' : ''); + setSubmitError(null); + + if (hasEmailError || hasNameError) return; + + setIsLoading(true); + try { + await apiFetch('/users/', { + method: 'POST', + token, + body: JSON.stringify({ email: email.toLowerCase(), name: name.trim(), isAdmin }), + }); + resetForm(); + onSuccess(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to create user'); + } finally { + setIsLoading(false); + } + } + + return ( + { if (!e.open) handleClose(); }}> + + + + + + + Add User + + + + + + + + + {submitError && ( +

{submitError}

+ )} +
+
+ + + + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/login/page.tsx b/apps/frontend/src/app/login/page.tsx index a996fa24..cf53331a 100644 --- a/apps/frontend/src/app/login/page.tsx +++ b/apps/frontend/src/app/login/page.tsx @@ -23,7 +23,7 @@ function LoginPageContent() { const next = safeNextPath(searchParams.get('next')); const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); + const [password, setPasswordValue] = useState(''); const [emailError, setEmailError] = useState(''); const [passwordError, setPasswordError] = useState(''); const [formError, setFormError] = useState(''); @@ -157,7 +157,7 @@ function LoginPageContent() { errorMessage={passwordError} isError={!!passwordError} value={password} - onChange={(value) => setPassword(value)} + onChange={(value) => setPasswordValue(value)} />