Skip to content
Merged
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
39 changes: 39 additions & 0 deletions apps/api/src/email/templates/access-granted.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { AccessGrantedEmail } from './access-granted';

const baseProps = {
toName: 'Chang Liu',
organizationName: 'Acme Security',
expiresAt: new Date('2026-12-31T00:00:00Z'),
portalUrl: 'https://portal.example.com/access/token',
};

describe('AccessGrantedEmail', () => {
it('omits all NDA copy when access was granted via allow-list bypass', () => {
const html = renderToStaticMarkup(
AccessGrantedEmail({ ...baseProps, ndaBypassed: true }),
);

// No NDA was signed, so the email must not reference one.
expect(html).not.toContain('NDA');
expect(html).not.toContain('signed');
// The access confirmation itself is still present.
expect(html).toContain('is now active');
expect(html).toContain('Acme Security');
});

it('includes NDA copy for the standard NDA-signed flow', () => {
const html = renderToStaticMarkup(
AccessGrantedEmail({ ...baseProps, ndaBypassed: false }),
);

expect(html).toContain('Your NDA has been signed');
expect(html).toContain('download your signed NDA');
});

it('defaults to the NDA-signed copy when ndaBypassed is omitted', () => {
const html = renderToStaticMarkup(AccessGrantedEmail({ ...baseProps }));

expect(html).toContain('Your NDA has been signed');
});
});
31 changes: 24 additions & 7 deletions apps/api/src/email/templates/access-granted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ interface Props {
organizationName: string;
expiresAt: Date;
portalUrl: string;
/**
* When true, access was granted without an NDA (the requester's email or
* domain is on the trust portal allow list). NDA-specific copy is omitted.
*/
ndaBypassed?: boolean;
}

export const AccessGrantedEmail = ({
toName,
organizationName,
expiresAt,
portalUrl,
ndaBypassed = false,
}: Props) => {
return (
<Html>
Expand Down Expand Up @@ -60,9 +66,18 @@ export const AccessGrantedEmail = ({
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Your NDA has been signed and your access to{' '}
<strong>{organizationName}</strong>'s policy documentation is now
active.
{ndaBypassed ? (
<>
Your access to <strong>{organizationName}</strong>'s policy
documentation is now active.
</>
) : (
<>
Your NDA has been signed and your access to{' '}
<strong>{organizationName}</strong>'s policy documentation is
now active.
</>
)}
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Expand All @@ -85,10 +100,12 @@ export const AccessGrantedEmail = ({
</Button>
</Section>

<Text className="text-[14px] leading-[24px] text-[#121212]">
You can download your signed NDA for your records from the
confirmation page or by accessing the portal above.
</Text>
{!ndaBypassed && (
<Text className="text-[14px] leading-[24px] text-[#121212]">
You can download your signed NDA for your records from the
confirmation page or by accessing the portal above.
</Text>
)}

<Section
className="mt-[30px] mb-[20px] rounded-[3px] border-l-4 p-[15px]"
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/trust-portal/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,16 @@ export class TrustEmailService {
organizationName: string;
expiresAt: Date;
portalUrl: string;
ndaBypassed?: boolean;
}): Promise<void> {
const { toEmail, toName, organizationName, expiresAt, portalUrl } = params;
const {
toEmail,
toName,
organizationName,
expiresAt,
portalUrl,
ndaBypassed,
} = params;

const { id } = await triggerEmail({
to: toEmail,
Expand All @@ -48,6 +56,7 @@ export class TrustEmailService {
organizationName,
expiresAt,
portalUrl,
ndaBypassed,
}),
trustPortal: true,
});
Expand Down
135 changes: 135 additions & 0 deletions apps/api/src/trust-portal/trust-access.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ jest.mock('@db', () => ({
},
trustAccessGrant: {
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
trustAccessRequest: {
findFirst: jest.fn(),
Expand Down Expand Up @@ -62,6 +64,8 @@ const mockDb = db as unknown as {
};
trustAccessGrant: {
findUnique: jest.Mock;
findFirst: jest.Mock;
update: jest.Mock;
};
trustAccessRequest: {
findFirst: jest.Mock;
Expand Down Expand Up @@ -262,6 +266,10 @@ describe('TrustAccessService approveRequest NDA bypass', () => {
expect(txMock.trustAccessGrant.create).toHaveBeenCalledTimes(1);
expect(txMock.trustNDAAgreement.create).not.toHaveBeenCalled();
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1);
// The granted email must omit NDA copy since no NDA was signed.
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
expect.objectContaining({ ndaBypassed: true }),
);
expect(emailService.sendNdaSigningEmail).not.toHaveBeenCalled();
expect(txMock.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -286,6 +294,9 @@ describe('TrustAccessService approveRequest NDA bypass', () => {

expect(txMock.trustAccessGrant.create).toHaveBeenCalledTimes(1);
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1);
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
expect.objectContaining({ ndaBypassed: true }),
);
expect(txMock.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
Expand All @@ -310,3 +321,127 @@ describe('TrustAccessService approveRequest NDA bypass', () => {
expect(result.message).toBe('NDA signing email sent');
});
});

describe('TrustAccessService resendAccessGrantEmail NDA copy', () => {
const emailService = {
sendAccessGrantedEmail: jest.fn(),
};
const service = new TrustAccessService(
{} as any,
emailService as any,
{} as any,
{} as any,
{} as any,
);
jest
.spyOn(service as any, 'buildPortalAccessUrl')
.mockResolvedValue('https://portal.example.com/access/token');

const baseGrant = {
id: 'tag_1',
subjectEmail: 'chang.liu@client.com',
status: 'active',
expiresAt: new Date(Date.now() + 86_400_000),
accessToken: 'existing-token',
accessTokenExpiresAt: new Date(Date.now() + 86_400_000),
accessRequest: {
name: 'Chang Liu',
organization: { name: 'Acme Security' },
},
};

beforeEach(() => {
jest.clearAllMocks();
});

it('marks the resent email as bypassed when the grant has no NDA agreement', async () => {
mockDb.trustAccessGrant.findFirst.mockResolvedValue({
...baseGrant,
ndaAgreement: null,
});

await service.resendAccessGrantEmail('org_1', 'tag_1');

expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
expect.objectContaining({ ndaBypassed: true }),
);
});

it('keeps NDA copy when the grant has a signed NDA agreement', async () => {
mockDb.trustAccessGrant.findFirst.mockResolvedValue({
...baseGrant,
ndaAgreement: { status: 'signed' },
});

await service.resendAccessGrantEmail('org_1', 'tag_1');

expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
expect.objectContaining({ ndaBypassed: false }),
);
});
});

describe('TrustAccessService signNda NDA copy', () => {
const ndaPdfService = {
generateNdaPdf: jest.fn().mockResolvedValue(Buffer.from('pdf')),
uploadNdaPdf: jest.fn().mockResolvedValue('org_1/nda/nda_1.pdf'),
getSignedUrl: jest.fn().mockResolvedValue('https://s3.example.com/nda.pdf'),
};
const emailService = {
sendAccessGrantedEmail: jest.fn(),
};
const service = new TrustAccessService(
ndaPdfService as any,
emailService as any,
{} as any,
{} as any,
{} as any,
);
jest
.spyOn(service as any, 'buildPortalAccessUrl')
.mockResolvedValue('https://portal.example.com/access/token');

beforeEach(() => {
jest.clearAllMocks();
mockDb.trustNDAAgreement.findUnique.mockResolvedValue({
id: 'nda_1',
organizationId: 'org_1',
accessRequestId: 'tar_1',
status: 'pending',
signTokenExpiresAt: new Date(Date.now() + 86_400_000),
grant: null,
accessRequest: {
requestedDurationDays: 30,
organization: { name: 'Acme Security' },
},
});
mockDb.$transaction.mockImplementation(
(cb: (tx: unknown) => Promise<unknown>) =>
cb({
trustAccessGrant: {
create: jest
.fn()
.mockResolvedValue({ id: 'tag_1', expiresAt: new Date() }),
},
trustNDAAgreement: {
update: jest.fn().mockResolvedValue({ id: 'nda_1' }),
},
}),
);
});

it('sends the granted email with NDA copy (ndaBypassed: false) after signing', async () => {
await service.signNda(
'sign-token',
'Chang Liu',
'chang.liu@client.com',
'1.2.3.4',
'jest-agent',
);

expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1);
expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith(
expect.objectContaining({ ndaBypassed: false }),
);
});
});
8 changes: 8 additions & 0 deletions apps/api/src/trust-portal/trust-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@ export class TrustAccessService {
organizationName: request.organization.name,
expiresAt: result.grant.expiresAt,
portalUrl,
ndaBypassed: true,
});

return {
Expand Down Expand Up @@ -976,6 +977,9 @@ export class TrustAccessService {
},
},
},
ndaAgreement: {
select: { status: true },
},
},
});

Expand Down Expand Up @@ -1028,6 +1032,9 @@ export class TrustAccessService {
organizationName: grant.accessRequest.organization.name,
expiresAt: grant.expiresAt,
portalUrl,
// A bypassed grant has no NDA agreement; an NDA-signed grant links a
// 'signed' one. Mirror the original email's copy when resending.
ndaBypassed: grant.ndaAgreement?.status !== 'signed',
});

return { message: 'Access email resent successfully' };
Expand Down Expand Up @@ -1239,6 +1246,7 @@ export class TrustAccessService {
organizationName: nda.accessRequest.organization.name,
expiresAt: result.grant.expiresAt,
portalUrl,
ndaBypassed: false,
});

const pdfUrl = await this.ndaPdfService.getSignedUrl(pdfKey);
Expand Down
Loading