feat(dpa): self-serve Data Processing Agreement generator (#22243)

## What

A single, region-aware DPA that serves all customers, generated
automatically from the customer's deployment. Two layers:

1. **Click-through DPA** — recorded at signup (acceptance = execution),
resolving merge fields from the deployment region. Cloud only.
2. **In-app signed-PDF generator** — Settings → Legal → Generate DPA:
preview the agreement, enter legal entity + authorized signatory,
download a PDF pre-signed by Twenty, and store the executed copy against
the workspace with its template version + timestamp. Deep-linkable at
`/dpa` (login-gated) for `twenty.com/dpa`.

## How it resolves

A typed variable matrix (`dpa-region-config.constant.ts`) maps the
deployment region to the contracting Processor entity and terms:

- **EU (default)** → Twenty.com SAS, hosting EU/Frankfurt, governing law
France, SCC section dormant.
- **US (custom)** → Twenty, Inc., hosting US, SCC section active.

Region is a deployment-wide setting (`DPA_DEPLOYMENT_REGION`, default
EU) behind a `DpaRegionService` seam so it can later become
per-workspace without touching callers. The legal text is verbatim from
the template (generated into `dpa-template.constant.ts` directly from
the source `.docx`); only the 6 merge fields are filled and the SCC
sections (7.2–7.5) stay in the document for every region per the spec —
only field values branch. Sub-processors are deferred to
trust.twenty.com (not enumerated). Billing stays decoupled (Twenty, Inc.
remains merchant of record regardless of Processor).

## UI

Standard list + create-page pattern (mirrors API keys / webhooks): a
list of executed copies (with re-download) — or the agreement preview
when none exists — and a top-right blue **Generate DPA** CTA opening a
standard create page. The "Legal" item is intentionally **not** in the
settings menu; the page is reached via the `/dpa` deep link.

## Notable implementation details

- **PDF** is rendered server-side with `@react-pdf/renderer`. The
built-in standard-14 fonts only encode ASCII and crash on the template's
curly quotes / em–en dashes / accented Latin, so Liberation Sans (OFL)
is **subset to a Latin glyph set and embedded as base64 data: URLs** —
no font files to ship or resolve at runtime (works in dev, prod-Docker
and CI).
- New `core.dpaAgreement` table via a fast instance command (FK hash
reproduced to match TypeORM).
- Self-hosted deployments (billing disabled) skip click-through
recording and stamp a prominent "not a valid agreement" banner on the
preview and PDF.

## Tests

- Unit: resolver (per-region entity/law/SCC state, EU default, no
unresolved `{{ }}`, SCC sections present in both regions, self-hosted
notice) and HTML renderer.
- Integration (`test/integration/graphql/suites/dpa`): preview has no
unresolved fields; `generateSignedDpa` renders + persists + returns a
downloadable PDF (asserted with accented input to guard the font
regression); list re-download.

## ⚠ Needs legal input before go-live (marked `TODO_CONFIRM` in
`dpa-region-config.constant.ts`)

- Registered-office addresses for Twenty.com SAS and Twenty, Inc.
- US deployment governing law (the template only specifies France).
- DPO name and the Twenty pre-signed authorized signatory name/title.

## Out of scope (flagged per spec)

Intra-group legal agreement and any Stripe/billing-entity changes. A
future e-sign provider would plug in at `DpaService.generateSignedDpa` +
the signatory input.

> Draft until the integration test passes in CI and the legal
`TODO_CONFIRM` values are supplied.

https://claude.ai/code/session_01Ahjydxx6J1souz1s1NeA9a

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Ahjydxx6J1souz1s1NeA9a)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22243?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-27 17:46:38 +02:00
committed by GitHub
parent 77c84815ef
commit 538b180824
64 changed files with 3621 additions and 3 deletions
@@ -0,0 +1,86 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type DpaDocument } from '@/settings/legal/types/Dpa';
const StyledDocument = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.sm};
line-height: 1.5;
max-height: 460px;
overflow-y: auto;
padding: ${themeCssVariables.spacing[6]};
`;
const StyledTitle = styled.h1`
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin: 0;
`;
const StyledLastUpdated = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.xs};
margin-bottom: ${themeCssVariables.spacing[4]};
margin-top: ${themeCssVariables.spacing[1]};
`;
const StyledHeading = styled.h2`
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin-bottom: ${themeCssVariables.spacing[1]};
margin-top: ${themeCssVariables.spacing[4]};
`;
const StyledParagraph = styled.p`
color: ${themeCssVariables.font.color.secondary};
margin: ${themeCssVariables.spacing[2]} 0;
text-align: justify;
`;
const StyledSignatureField = styled.div`
margin: ${themeCssVariables.spacing[2]} 0;
`;
const StyledSignatureLabel = styled.div`
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledSignatureValue = styled.div`
color: ${themeCssVariables.font.color.secondary};
white-space: pre-wrap;
`;
type DpaDocumentPreviewProps = {
document: DpaDocument;
};
export const DpaDocumentPreview = ({ document }: DpaDocumentPreviewProps) => (
// tabIndex makes the scrollable region focusable so keyboard-only users can
// scroll the agreement with the arrow keys.
<StyledDocument tabIndex={0} role="region" aria-label={document.title}>
<StyledTitle>{document.title}</StyledTitle>
<StyledLastUpdated>
Last Updated: {document.lastUpdatedLabel}
</StyledLastUpdated>
{document.blocks.map((block, index) => {
if (block.kind === 'heading') {
return <StyledHeading key={index}>{block.text}</StyledHeading>;
}
if (block.kind === 'signatureField') {
return (
<StyledSignatureField key={index}>
<StyledSignatureLabel>{block.label}</StyledSignatureLabel>
<StyledSignatureValue>{block.value}</StyledSignatureValue>
</StyledSignatureField>
);
}
return <StyledParagraph key={index}>{block.text}</StyledParagraph>;
})}
</StyledDocument>
);
@@ -0,0 +1,18 @@
import { styled } from '@linaria/react';
import { Info } from 'twenty-ui/feedback';
const StyledFullWidthInfo = styled.div`
& > * {
max-width: 100%;
}
`;
type DpaNoticeProps = {
text: string;
};
export const DpaNotice = ({ text }: DpaNoticeProps) => (
<StyledFullWidthInfo>
<Info accent="danger" text={text} />
</StyledFullWidthInfo>
);
@@ -0,0 +1,57 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Button } from 'twenty-ui/input';
import { IconDownload } from 'twenty-ui/icon';
import { type DpaAgreement } from '@/settings/legal/types/Dpa';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { beautifyExactDateTime } from '~/utils/date-utils';
export const DPA_AGREEMENT_ROW_GRID_COLUMNS = '3fr 2fr 2fr 140px';
const StyledEllipsisLabel = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
type SettingsDpaAgreementRowProps = {
agreement: DpaAgreement;
onDownload: (agreement: DpaAgreement) => void;
};
export const SettingsDpaAgreementRow = ({
agreement,
onDownload,
}: SettingsDpaAgreementRowProps) => {
const { t } = useLingui();
const label =
agreement.type === 'SIGNED'
? (agreement.customerLegalEntityName ?? t`Signed copy`)
: t`Click-through acceptance`;
return (
<TableRow gridAutoColumns={DPA_AGREEMENT_ROW_GRID_COLUMNS}>
<TableCell whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
<StyledEllipsisLabel>{label}</StyledEllipsisLabel>
</TableCell>
<TableCell>{agreement.templateVersion}</TableCell>
<TableCell>{beautifyExactDateTime(agreement.acceptedAt)}</TableCell>
<TableCell align="right">
{agreement.downloadUrl ? (
<Button
Icon={IconDownload}
title={t`Download`}
size="small"
variant="tertiary"
onClick={() => onDownload(agreement)}
/>
) : (
'—'
)}
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,75 @@
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
DPA_AGREEMENT_ROW_GRID_COLUMNS,
SettingsDpaAgreementRow,
} from '@/settings/legal/components/SettingsDpaAgreementRow';
import { type DpaAgreement } from '@/settings/legal/types/Dpa';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { downloadFile } from '@/activities/files/utils/downloadFile';
const StyledTableBodyContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
`;
type SettingsDpaAgreementsTableProps = {
agreements: DpaAgreement[];
};
export const SettingsDpaAgreementsTable = ({
agreements,
}: SettingsDpaAgreementsTableProps) => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const handleDownload = async (agreement: DpaAgreement) => {
if (!agreement.downloadUrl) {
return;
}
try {
await downloadFile(
agreement.downloadUrl,
`Twenty-DPA-${agreement.templateVersion}-${agreement.customerLegalEntityName ?? 'copy'}.pdf`,
);
} catch {
enqueueErrorSnackBar({ message: t`Could not download the document.` });
}
};
return (
<Table>
<TableRow gridAutoColumns={DPA_AGREEMENT_ROW_GRID_COLUMNS}>
<TableHeader>
<Trans>Document</Trans>
</TableHeader>
<TableHeader>
<Trans>Version</Trans>
</TableHeader>
<TableHeader>
<Trans>Date</Trans>
</TableHeader>
<TableHeader></TableHeader>
</TableRow>
{agreements.length > 0 && (
<StyledTableBodyContainer>
<TableBody>
{agreements.map((agreement) => (
<SettingsDpaAgreementRow
key={agreement.id}
agreement={agreement}
onDownload={handleDownload}
/>
))}
</TableBody>
</StyledTableBodyContainer>
)}
</Table>
);
};
@@ -0,0 +1,22 @@
import gql from 'graphql-tag';
export const GENERATE_SIGNED_DPA = gql`
mutation GenerateSignedDpa($input: GenerateSignedDpaInput!) {
generateSignedDpa(input: $input) {
downloadUrl
agreement {
id
type
templateVersion
region
processorEntity
customerLegalEntityName
signatoryName
signatoryTitle
acceptedByEmail
acceptedAt
createdAt
}
}
}
`;
@@ -0,0 +1,20 @@
import gql from 'graphql-tag';
export const GET_DPA_AGREEMENTS = gql`
query GetDpaAgreements {
dpaAgreements {
id
type
templateVersion
region
processorEntity
customerLegalEntityName
signatoryName
signatoryTitle
acceptedByEmail
acceptedAt
createdAt
downloadUrl
}
}
`;
@@ -0,0 +1,21 @@
import gql from 'graphql-tag';
export const GET_DPA_PREVIEW = gql`
query GetDpaPreview {
dpaPreview {
title
lastUpdatedLabel
templateVersion
region
processorEntity
sccSectionActive
notice
blocks {
kind
text
label
value
}
}
}
`;
@@ -0,0 +1,39 @@
export type DpaAgreementType = 'CLICK_THROUGH' | 'SIGNED';
export type DpaDocumentBlock = {
kind: string;
text: string;
label?: string | null;
value?: string | null;
};
export type DpaDocument = {
title: string;
lastUpdatedLabel: string;
templateVersion: string;
region: string;
processorEntity: string;
sccSectionActive: boolean;
notice?: string | null;
blocks: DpaDocumentBlock[];
};
export type DpaAgreement = {
id: string;
type: DpaAgreementType;
templateVersion: string;
region: string;
processorEntity: string;
customerLegalEntityName?: string | null;
signatoryName?: string | null;
signatoryTitle?: string | null;
acceptedByEmail?: string | null;
acceptedAt: string;
createdAt: string;
downloadUrl?: string | null;
};
export type GenerateSignedDpaResult = {
downloadUrl: string;
agreement: DpaAgreement;
};