Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/backend/lambdas/auth/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions apps/backend/lambdas/auth/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,13 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// >>> 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);
Expand Down Expand Up @@ -200,7 +200,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(500, { message: 'Failed to resend verification code' });
}
}

// POST /logout
if (normalizedPath === '/logout' && method === 'POST') {
const authHeader = event.headers?.authorization || event.headers?.Authorization;
Expand All @@ -209,8 +209,8 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
}

// 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) {
Expand All @@ -234,7 +234,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
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<string, unknown> : {};
Expand Down Expand Up @@ -270,7 +270,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
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<string, unknown> : {};
Expand Down Expand Up @@ -309,7 +309,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(500, { message: 'Failed to reset password' });
}
}
// <<< ROUTES-END
// <<< ROUTES-END

return json(404, { message: 'Not Found', path: normalizedPath, method });
} catch (err) {
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/lambdas/donors/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/lambdas/expenditures/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/lambdas/projects/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/lambdas/reports/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/lambdas/users/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/
Expand Down
45 changes: 40 additions & 5 deletions apps/backend/lambdas/users/handler.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -253,7 +254,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
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)
Expand All @@ -263,15 +264,49 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
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' });
}

Expand Down
3 changes: 3 additions & 0 deletions apps/backend/lambdas/users/test/user.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/lambdas/users/test/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof authenticateRequest>;
const mockCheckAuthorization = checkAuthorization as jest.MockedFunction<typeof checkAuthorization>;

Expand Down
24 changes: 21 additions & 3 deletions apps/frontend/src/app/accounts/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="!p-6">
<h1 className="![font-family:var(--font-heading)] !text-[length:var(--font-size-heading-1)] !font-semibold">Accounts</h1>
<div className="flex items-center justify-between !mb-4">
<h1 className="![font-family:var(--font-heading)] !text-[length:var(--font-size-heading-1)] !font-semibold">Accounts</h1>
<Button
backgroundColor="var(--color-core-green)"
color="var(--color-core-white)"
onClick={() => setIsModalOpen(true)}
>
+ Add User
</Button>
</div>
<h3 className="![font-family:var(--font-heading)] !text-[length:var(--font-size-heading-3)] !font-semibold">Core BRANCH Facilitation Team</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 !pt-3 !pb-7">
{facilitationTeam.map(user => (
Expand All @@ -20,6 +33,11 @@ export default function AccountsPage() {
<StaffCard key={user.user_id} name={user.name} email={user.email} />
))}
</div>
<AddUserModal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSuccess={() => setIsModalOpen(false)}
/>
</div>
);
}
}
131 changes: 131 additions & 0 deletions apps/frontend/src/app/components/AddUserModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<Dialog.Root open={open} onOpenChange={(e) => { if (!e.open) handleClose(); }}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header display="flex" justifyContent="space-between" alignItems="center" backgroundColor="var(--color-black-100)">
<Dialog.Title
fontFamily="var(--font-heading)"
fontSize="var(--font-size-heading-3)"
fontWeight={600}
>
Add User
</Dialog.Title>
<CloseButton onClick={handleClose} />
</Dialog.Header>
<Dialog.Body>
<Stack gap={4}>
<TextInputField
label="Email *"
placeholder="Enter email address"
value={email}
onChange={setEmail}
isError={!!emailError}
errorMessage={emailError}
/>
<TextInputField
label="Name *"
placeholder="Enter full name"
value={name}
onChange={setName}
isError={!!nameError}
errorMessage={nameError}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontSize: '14px' }}>
<input
type="checkbox"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
style={{ width: '16px', height: '16px' }}
/>
Admin
</label>
{submitError && (
<p style={{ color: 'var(--color-error-red)', fontSize: '14px' }}>{submitError}</p>
)}
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Button
variant="outline"
borderColor="var(--color-core-green)"
onClick={handleClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
backgroundColor="var(--color-core-green)"
color="var(--color-core-white)"
onClick={handleSubmit}
loading={isLoading}
>
Add User
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
);
}
Loading
Loading