diff --git a/.github/workflows/ci-dpa-subprocessors-sync.yaml b/.github/workflows/ci-dpa-subprocessors-sync.yaml new file mode 100644 index 0000000000..0eaae2c49b --- /dev/null +++ b/.github/workflows/ci-dpa-subprocessors-sync.yaml @@ -0,0 +1,79 @@ +name: DPA Sub-Processor Sync + +on: + schedule: + - cron: '0 6 * * 1' # Weekly, Monday at 6 AM UTC + workflow_dispatch: # Allow manual trigger + +permissions: + contents: write + pull-requests: write + +jobs: + sync-subprocessors: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + NODE_OPTIONS: '--max-old-space-size=4096' + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: main + + - name: Install dependencies + uses: ./.github/actions/yarn-install + + - name: Build dependencies + run: npx nx build twenty-shared + + - name: Run sub-processor sync + run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/dpa-sync-subprocessors.ts + + - name: Check for changes + id: changes + run: | + if git diff --quiet packages/twenty-server/src/engine/core-modules/dpa/constants/subprocessors.json; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Create pull request + if: steps.changes.outputs.changed == 'true' + uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: sync DPA sub-processors from trust center' + title: 'chore: sync DPA sub-processors from trust center' + body: | + Automated weekly sync of `subprocessors.json` from Twenty's Trust Center (OneLeet). + + This keeps the DPA's Annex C (the SCC Annex III list of Sub-Processors) in + lockstep with the canonical list at https://trust.twenty.com — the Trust + Center is the single source of truth; this file is generated from it. + + **Please review before merging** — confirm the added/removed Sub-Processors + are expected, and that customers were notified per Section 6.2 where required. + branch: chore/dpa-subprocessors-sync + base: main + labels: automated + delete-branch: true + + - name: Mint twenty-infra dispatch token + id: app-token + if: steps.changes.outputs.changed == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.TWENTY_WORKFLOW_DISPATCHER_CLIENT_ID }} + private-key: ${{ secrets.TWENTY_WORKFLOW_DISPATCHER_PRIVATE_KEY }} + owner: twentyhq + repositories: twenty-infra + permission-actions: write + + - name: Trigger automerge + if: steps.changes.outputs.changed == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh workflow run automerge-i18n.yaml --repo twentyhq/twenty-infra --ref main diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 88af092142..1046d09f7c 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -4653,6 +4653,7 @@ enum FileFolder { EmailAttachment AppTarball GeneratedSdkClient + Dpa } type Subscription { diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 82047f3fef..50b8f4570e 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -3001,7 +3001,7 @@ export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update' export type AnalyticsType = 'PAGEVIEW' | 'TRACK' -export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' +export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa' export interface Subscription { onEventSubscription?: EventSubscription @@ -9346,5 +9346,6 @@ export const enumFileFolder = { Workflow: 'Workflow' as const, EmailAttachment: 'EmailAttachment' as const, AppTarball: 'AppTarball' as const, - GeneratedSdkClient: 'GeneratedSdkClient' as const + GeneratedSdkClient: 'GeneratedSdkClient' as const, + Dpa: 'Dpa' as const } diff --git a/packages/twenty-front/codegen-metadata.cjs b/packages/twenty-front/codegen-metadata.cjs index 3dd5081043..e6dbb6072b 100644 --- a/packages/twenty-front/codegen-metadata.cjs +++ b/packages/twenty-front/codegen-metadata.cjs @@ -17,6 +17,9 @@ module.exports = { './src/modules/settings/**/graphql/**/*.{ts,tsx}', '!./src/modules/settings/admin-panel/**/graphql/**/*.{ts,tsx}', + // DPA operations are served by the core (/graphql) schema via @CoreResolver, + // not the metadata schema, so they must not be validated against /metadata. + '!./src/modules/settings/legal/**/graphql/**/*.{ts,tsx}', './src/modules/logic-functions/graphql/**/*.{ts,tsx}', './src/modules/databases/graphql/**/*.{ts,tsx}', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d646d6f8d5..7b481670a2 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1896,6 +1896,7 @@ export enum FileFolder { BuiltLogicFunction = 'BuiltLogicFunction', CorePicture = 'CorePicture', Dependencies = 'Dependencies', + Dpa = 'Dpa', EmailAttachment = 'EmailAttachment', File = 'File', FilesField = 'FilesField', diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 3c1a5a5455..fd5e4e1d51 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -92,6 +92,60 @@ export type DeleteWorkflowVersionStepInput = { workflowVersionId: Scalars['UUID']['input']; }; +export type DpaAgreement = { + __typename?: 'DpaAgreement'; + acceptedAt: Scalars['DateTime']['output']; + acceptedByEmail?: Maybe; + createdAt: Scalars['DateTime']['output']; + customerLegalEntityName?: Maybe; + downloadUrl?: Maybe; + id: Scalars['UUID']['output']; + processorEntity: Scalars['String']['output']; + region: DpaRegion; + signatoryName?: Maybe; + signatoryTitle?: Maybe; + signedFileId?: Maybe; + templateVersion: Scalars['String']['output']; + type: DpaAgreementType; + updatedAt: Scalars['DateTime']['output']; +}; + +export enum DpaAgreementType { + CLICK_THROUGH = 'CLICK_THROUGH', + SIGNED = 'SIGNED' +} + +export type DpaDocument = { + __typename?: 'DpaDocument'; + blocks: Array; + lastUpdatedLabel: Scalars['String']['output']; + notice?: Maybe; + processorEntity: Scalars['String']['output']; + region: DpaRegion; + sccSectionActive: Scalars['Boolean']['output']; + templateVersion: Scalars['String']['output']; + title: Scalars['String']['output']; +}; + +export type DpaDocumentBlock = { + __typename?: 'DpaDocumentBlock'; + kind: DpaDocumentBlockKind; + label?: Maybe; + text: Scalars['String']['output']; + value?: Maybe; +}; + +export enum DpaDocumentBlockKind { + Heading = 'Heading', + Paragraph = 'Paragraph', + SignatureField = 'SignatureField' +} + +export enum DpaRegion { + EU = 'EU', + US = 'US' +} + export type DuplicateWorkflowInput = { /** Workflow ID to duplicate */ workflowIdToDuplicate: Scalars['UUID']['input']; @@ -109,6 +163,18 @@ export enum FilterIs { Null = 'Null' } +export type GenerateSignedDpaInput = { + customerLegalEntityName: Scalars['String']['input']; + signatoryName: Scalars['String']['input']; + signatoryTitle: Scalars['String']['input']; +}; + +export type GenerateSignedDpaResult = { + __typename?: 'GenerateSignedDpaResult'; + agreement: DpaAgreement; + downloadUrl: Scalars['String']['output']; +}; + export type LinkMetadata = { __typename?: 'LinkMetadata'; label: Scalars['String']['output']; @@ -142,6 +208,7 @@ export type Mutation = { dismissReconnectAccountBanner: Scalars['Boolean']['output']; duplicateWorkflow: WorkflowVersionDto; duplicateWorkflowVersionStep: WorkflowVersionStepChanges; + generateSignedDpa: GenerateSignedDpaResult; retryWorkflowRun: WorkflowRun; runWorkflowVersion: RunWorkflowVersion; stopWorkflowRun: WorkflowRun; @@ -208,6 +275,11 @@ export type MutationDuplicateWorkflowVersionStepArgs = { }; +export type MutationGenerateSignedDpaArgs = { + input: GenerateSignedDpaInput; +}; + + export type MutationRetryWorkflowRunArgs = { workflowRunId: Scalars['UUID']['input']; }; @@ -259,6 +331,8 @@ export type ObjectRecordFilterInput = { export type Query = { __typename?: 'Query'; + dpaAgreements: Array; + dpaPreview: DpaDocument; /** @deprecated Use getTimelineCalendarEventsFromObjectRecord instead */ getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromObjectRecord: TimelineCalendarEventsWithTotal; diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 014b6fd0fb..9b85797554 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -114,6 +114,18 @@ const SettingsGeneral = lazy(() => })), ); +const SettingsLegalDpa = lazy(() => + import('~/pages/settings/legal/SettingsLegalDpa').then((module) => ({ + default: module.SettingsLegalDpa, + })), +); + +const SettingsLegalDpaNew = lazy(() => + import('~/pages/settings/legal/SettingsLegalDpaNew').then((module) => ({ + default: module.SettingsLegalDpaNew, + })), +); + const SettingsWorkspaceEmail = lazy(() => import('~/pages/settings/email/SettingsWorkspaceEmail').then((module) => ({ default: module.SettingsWorkspaceEmail, @@ -682,6 +694,11 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => ( path={SettingsPath.PublicDomain} element={} /> + } /> + } + /> } /> + {/* Deep link for twenty.com/dpa → in-app generator. This route is + inside the authenticated layout, so an unauthenticated hit is + login-gated and returns here after sign-in. */} + + } + /> ( + // tabIndex makes the scrollable region focusable so keyboard-only users can + // scroll the agreement with the arrow keys. + + {document.title} + + Last Updated: {document.lastUpdatedLabel} + + {document.blocks.map((block, index) => { + if (block.kind === 'heading') { + return {block.text}; + } + + if (block.kind === 'signatureField') { + return ( + + {block.label} + {block.value} + + ); + } + + return {block.text}; + })} + +); diff --git a/packages/twenty-front/src/modules/settings/legal/components/DpaNotice.tsx b/packages/twenty-front/src/modules/settings/legal/components/DpaNotice.tsx new file mode 100644 index 0000000000..d7a27ce8c9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/legal/components/DpaNotice.tsx @@ -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) => ( + + + +); diff --git a/packages/twenty-front/src/modules/settings/legal/components/SettingsDpaAgreementRow.tsx b/packages/twenty-front/src/modules/settings/legal/components/SettingsDpaAgreementRow.tsx new file mode 100644 index 0000000000..bb30345b2e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/legal/components/SettingsDpaAgreementRow.tsx @@ -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 ( + + + {label} + + {agreement.templateVersion} + {beautifyExactDateTime(agreement.acceptedAt)} + + {agreement.downloadUrl ? ( +