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:
@@ -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>
|
||||
);
|
||||
+57
@@ -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>
|
||||
);
|
||||
};
|
||||
+75
@@ -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>
|
||||
);
|
||||
};
|
||||
+22
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user