From 99207602754943c41374f6bd9c0860bceed0e0ae Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 11 Jun 2026 14:34:34 -0400 Subject: [PATCH] fix(trust-portal): use NDA-free copy in access-granted email when NDA is bypassed Trust Portal access auto-granted via the allow list (allowedDomains / allowedEmails) bypasses NDA signing, but the "Access Granted" confirmation email still rendered NDA-signed copy ("Your NDA has been signed", "download your signed NDA"). Both the bypass path (approveWithoutNda) and the signing path (signNda) call the same sendAccessGrantedEmail, so bypassed recipients saw wording about an NDA they never signed. Add an optional `ndaBypassed` flag threaded from the call sites through sendAccessGrantedEmail into the AccessGrantedEmail template, which omits the NDA sentences when set. approveWithoutNda passes true, signNda passes false, and resendAccessGrantEmail derives it from the grant's linked NDA agreement (grant.ndaAgreement?.status !== 'signed'). Tests: template render tests for both copy variants, plus service-level tests asserting the flag on all three call sites (bypass, resend, signNda). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../email/templates/access-granted.spec.ts | 39 +++++ .../src/email/templates/access-granted.tsx | 31 +++- apps/api/src/trust-portal/email.service.ts | 11 +- .../trust-portal/trust-access.service.spec.ts | 135 ++++++++++++++++++ .../src/trust-portal/trust-access.service.ts | 8 ++ 5 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/email/templates/access-granted.spec.ts diff --git a/apps/api/src/email/templates/access-granted.spec.ts b/apps/api/src/email/templates/access-granted.spec.ts new file mode 100644 index 0000000000..d872d46111 --- /dev/null +++ b/apps/api/src/email/templates/access-granted.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/email/templates/access-granted.tsx b/apps/api/src/email/templates/access-granted.tsx index a94bfce64b..39f156ab05 100644 --- a/apps/api/src/email/templates/access-granted.tsx +++ b/apps/api/src/email/templates/access-granted.tsx @@ -18,6 +18,11 @@ 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 = ({ @@ -25,6 +30,7 @@ export const AccessGrantedEmail = ({ organizationName, expiresAt, portalUrl, + ndaBypassed = false, }: Props) => { return ( @@ -60,9 +66,18 @@ export const AccessGrantedEmail = ({ - Your NDA has been signed and your access to{' '} - {organizationName}'s policy documentation is now - active. + {ndaBypassed ? ( + <> + Your access to {organizationName}'s policy + documentation is now active. + + ) : ( + <> + Your NDA has been signed and your access to{' '} + {organizationName}'s policy documentation is + now active. + + )} @@ -85,10 +100,12 @@ export const AccessGrantedEmail = ({ - - You can download your signed NDA for your records from the - confirmation page or by accessing the portal above. - + {!ndaBypassed && ( + + You can download your signed NDA for your records from the + confirmation page or by accessing the portal above. + + )}
{ - const { toEmail, toName, organizationName, expiresAt, portalUrl } = params; + const { + toEmail, + toName, + organizationName, + expiresAt, + portalUrl, + ndaBypassed, + } = params; const { id } = await triggerEmail({ to: toEmail, @@ -48,6 +56,7 @@ export class TrustEmailService { organizationName, expiresAt, portalUrl, + ndaBypassed, }), trustPortal: true, }); diff --git a/apps/api/src/trust-portal/trust-access.service.spec.ts b/apps/api/src/trust-portal/trust-access.service.spec.ts index 87fb26a09d..f5bab9ac1a 100644 --- a/apps/api/src/trust-portal/trust-access.service.spec.ts +++ b/apps/api/src/trust-portal/trust-access.service.spec.ts @@ -14,6 +14,8 @@ jest.mock('@db', () => ({ }, trustAccessGrant: { findUnique: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), }, trustAccessRequest: { findFirst: jest.fn(), @@ -62,6 +64,8 @@ const mockDb = db as unknown as { }; trustAccessGrant: { findUnique: jest.Mock; + findFirst: jest.Mock; + update: jest.Mock; }; trustAccessRequest: { findFirst: jest.Mock; @@ -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({ @@ -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({ @@ -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) => + 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 }), + ); + }); +}); diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index e2b9ee3824..6af1bc5c04 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -763,6 +763,7 @@ export class TrustAccessService { organizationName: request.organization.name, expiresAt: result.grant.expiresAt, portalUrl, + ndaBypassed: true, }); return { @@ -976,6 +977,9 @@ export class TrustAccessService { }, }, }, + ndaAgreement: { + select: { status: true }, + }, }, }); @@ -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' }; @@ -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);