diff --git a/apps/backend/lambdas/auth/Dockerfile b/apps/backend/lambdas/auth/Dockerfile index 82c773b7..3b62f649 100644 --- a/apps/backend/lambdas/auth/Dockerfile +++ b/apps/backend/lambdas/auth/Dockerfile @@ -6,6 +6,11 @@ WORKDIR /shared/types COPY shared/types/package.json ./ COPY shared/types/ ./ +WORKDIR /shared/lambda-auth +COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ +COPY shared/lambda-auth/src ./src/ +RUN npm install && npm run build + WORKDIR /app # Copy package files diff --git a/apps/backend/lambdas/auth/handler.ts b/apps/backend/lambdas/auth/handler.ts index 5ede5f7e..507d7010 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/donors/Dockerfile b/apps/backend/lambdas/donors/Dockerfile index dec1aba3..55811090 100644 --- a/apps/backend/lambdas/donors/Dockerfile +++ b/apps/backend/lambdas/donors/Dockerfile @@ -1,5 +1,9 @@ FROM node:20-alpine +WORKDIR /shared/types +COPY shared/types/package.json ./ +COPY shared/types/ ./ + WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/expenditures/Dockerfile b/apps/backend/lambdas/expenditures/Dockerfile index 17c7ce17..40e719df 100644 --- a/apps/backend/lambdas/expenditures/Dockerfile +++ b/apps/backend/lambdas/expenditures/Dockerfile @@ -1,5 +1,9 @@ FROM node:20-alpine +WORKDIR /shared/types +COPY shared/types/package.json ./ +COPY shared/types/ ./ + WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/projects/Dockerfile b/apps/backend/lambdas/projects/Dockerfile index 09292832..f925ad85 100644 --- a/apps/backend/lambdas/projects/Dockerfile +++ b/apps/backend/lambdas/projects/Dockerfile @@ -1,5 +1,9 @@ FROM node:20-alpine +WORKDIR /shared/types +COPY shared/types/package.json ./ +COPY shared/types/ ./ + WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/reports/Dockerfile b/apps/backend/lambdas/reports/Dockerfile index a959f797..25f88a0f 100644 --- a/apps/backend/lambdas/reports/Dockerfile +++ b/apps/backend/lambdas/reports/Dockerfile @@ -1,5 +1,9 @@ FROM node:20-alpine +WORKDIR /shared/types +COPY shared/types/package.json ./ +COPY shared/types/ ./ + WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/users/Dockerfile b/apps/backend/lambdas/users/Dockerfile index aec6cb5e..0560151b 100644 --- a/apps/backend/lambdas/users/Dockerfile +++ b/apps/backend/lambdas/users/Dockerfile @@ -1,5 +1,9 @@ FROM node:20-alpine +WORKDIR /shared/types +COPY shared/types/package.json ./ +COPY shared/types/ ./ + WORKDIR /shared/lambda-auth COPY shared/lambda-auth/package.json shared/lambda-auth/tsconfig.json ./ COPY shared/lambda-auth/src ./src/ diff --git a/apps/backend/lambdas/users/handler.ts b/apps/backend/lambdas/users/handler.ts index ef49f6e0..4f32be7a 100644 --- a/apps/backend/lambdas/users/handler.ts +++ b/apps/backend/lambdas/users/handler.ts @@ -1,6 +1,7 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { CognitoIdentityProviderClient, + AdminCreateUserCommand, AdminDeleteUserCommand, } from '@aws-sdk/client-cognito-identity-provider'; import db from './db' @@ -253,7 +254,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) @@ -263,15 +264,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/test/user.unit.test.ts b/apps/backend/lambdas/users/test/user.unit.test.ts index e60c7e7e..077e5b3c 100644 --- a/apps/backend/lambdas/users/test/user.unit.test.ts +++ b/apps/backend/lambdas/users/test/user.unit.test.ts @@ -336,6 +336,9 @@ describe('POST /users unit tests', () => { describe('Success Cases', () => { beforeEach(() => { mockAdminAuth(); + (mockSend as any).mockResolvedValue({ + User: { Attributes: [{ Name: 'sub', Value: 'test-cognito-sub-123' }] }, + }); }); test('201: successful POST returns 201 status and correct response shape', async () => { diff --git a/apps/backend/lambdas/users/test/users.test.ts b/apps/backend/lambdas/users/test/users.test.ts index d9f6ad89..c89d6e2f 100644 --- a/apps/backend/lambdas/users/test/users.test.ts +++ b/apps/backend/lambdas/users/test/users.test.ts @@ -20,6 +20,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 010a80d3..5bd1f7a5 100644 --- a/apps/frontend/src/app/accounts/page.tsx +++ b/apps/frontend/src/app/accounts/page.tsx @@ -1,13 +1,26 @@ '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 { facilitationTeam, teamMembers } from './mockUsers'; export default function AccountsPage() { + const [isModalOpen, setIsModalOpen] = useState(false); + return (
-

Accounts

+
+

Accounts

+ +

Core BRANCH Facilitation Team

{facilitationTeam.map(user => ( @@ -20,6 +33,11 @@ export default function AccountsPage() { ))}
+ setIsModalOpen(false)} + onSuccess={() => setIsModalOpen(false)} + />
); -} \ 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..8d0501db --- /dev/null +++ b/apps/frontend/src/app/components/AddUserModal.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useState } from 'react'; +import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; +import TextInputField from './TextInputField'; +import { useApi } from '@/hooks/useApi'; + +interface AddUserModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export default function AddUserModal({ open, onClose, onSuccess }: AddUserModalProps) { + const api = useApi(); + 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 api.post('/users/', { 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 cd3e8338..5375f413 100644 --- a/apps/frontend/src/app/login/page.tsx +++ b/apps/frontend/src/app/login/page.tsx @@ -25,7 +25,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(''); @@ -68,6 +68,9 @@ function LoginPageContent() { return; } // fetch rejects with a TypeError when the request never reached a server. + // Logged because that's not the only way to land here — any non-ApiError + // throw (e.g. a bug elsewhere in the login path) shows this same message. + console.error('Login failed with a non-ApiError:', err); setFormError('Cannot reach the server. Check your connection and try again.'); } @@ -159,7 +162,7 @@ function LoginPageContent() { errorMessage={passwordError} isError={!!passwordError} value={password} - onChange={(value) => setPassword(value)} + onChange={(value) => setPasswordValue(value)} />