Add processing activity, DPIA and TIA publish to document system

Replace the old PDF/snapshot-based exports for processing activities,
Data Protection Impact Assessments and Transfer Impact Assessments with
the publish document system. Includes GraphQL mutations, MCP tools, CLI
commands, n8n operations, frontend publish dialogs, e2e tests, and
prosemirror register templates that mirror the previous PDF layouts.

Each register lives as a generated DocumentTypeRegister document on the
organization, reused across publishes (the major version bumps on every
republish). Approvers can be passed in to create a draft pending
approval; otherwise the version is published immediately. The frontend
ProcessingActivities page exposes a Publish dropdown per register and a
Document link button per active tab, pre-fills the previous default
approvers, and navigates to the published document on success.

Remove snapshot mode entirely from these three entities: drop snapshotId
and sourceId from GraphQL schemas, types, filters, resolvers, MCP spec,
frontend routes and pages; remove SnapshotsTypeProcessingActivities from
the snapshot registry and delete the ProcessingActivities.Snapshot,
ProcessingActivitySnapshotter interface and *.InsertProcessingActivitySnapshots
methods. The snapshot_id columns remain in the database but are now
filtered out with snapshot_id IS NULL.

Add Get/Upsert/Clear GeneratedDocumentID methods on each entity type
(ProcessingActivity, DataProtectionImpactAssessment,
TransferImpactAssessment) backed by new columns in the generated_documents
table, matching the Finding/Obligation pattern.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-28 16:07:18 +02:00
parent be294599ec
commit ba8bce2ad3
60 changed files with 5461 additions and 3601 deletions

View File

@@ -12,12 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import {
downloadFile,
promisifyMutation,
sprintf,
toDateInput,
} from "@probo/helpers";
import { promisifyMutation, sprintf } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
@@ -25,14 +20,12 @@ import {
Badge,
Button,
Card,
Dropdown,
DropdownItem,
IconArrowDown,
IconChevronDown,
IconPageTextLine,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
Spinner,
TabItem,
Table,
Tabs,
@@ -52,7 +45,7 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { Link, useNavigate } from "react-router";
import type {
ProcessingActivitiesPageDPIAFragment$data,
@@ -68,8 +61,6 @@ import type {
} from "#/__generated__/core/ProcessingActivitiesPageTIAFragment.graphql";
import type { ProcessingActivityGraphDeleteMutation } from "#/__generated__/core/ProcessingActivityGraphDeleteMutation.graphql";
import type { ProcessingActivityGraphListQuery } from "#/__generated__/core/ProcessingActivityGraphListQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
@@ -84,6 +75,9 @@ import {
} from "../../../hooks/graph/ProcessingActivityGraph";
import { CreateProcessingActivityDialog } from "./dialogs/CreateProcessingActivityDialog";
import { PublishDataProtectionImpactAssessmentListDialog } from "./dialogs/PublishDataProtectionImpactAssessmentListDialog";
import { PublishProcessingActivityListDialog } from "./dialogs/PublishProcessingActivityListDialog";
import { PublishTransferImpactAssessmentListDialog } from "./dialogs/PublishTransferImpactAssessmentListDialog";
interface ProcessingActivitiesPageProps {
queryRef: PreloadedQuery<ProcessingActivityGraphListQuery>;
@@ -95,22 +89,15 @@ const processingActivitiesPageFragment = graphql`
@argumentDefinitions(
first: { type: "Int", defaultValue: 10 }
after: { type: "CursorKey" }
snapshotId: { type: "ID", defaultValue: null }
) {
id
processingActivities(
first: $first
after: $after
filter: { snapshotId: $snapshotId }
)
processingActivities(first: $first, after: $after)
@connection(
key: "ProcessingActivitiesPage_processingActivities"
filters: ["filter"]
) {
edges {
node {
id
snapshotId
name
purpose
dataSubjectCategory
@@ -139,17 +126,11 @@ const dpiaListPageFragment = graphql`
@argumentDefinitions(
first: { type: "Int", defaultValue: 10 }
after: { type: "CursorKey" }
snapshotId: { type: "ID", defaultValue: null }
) {
id
dataProtectionImpactAssessments(
first: $first
after: $after
filter: { snapshotId: $snapshotId }
)
dataProtectionImpactAssessments(first: $first, after: $after)
@connection(
key: "ProcessingActivitiesPage_dataProtectionImpactAssessments"
filters: ["filter"]
) {
edges {
node {
@@ -177,17 +158,11 @@ const tiaListPageFragment = graphql`
@argumentDefinitions(
first: { type: "Int", defaultValue: 10 }
after: { type: "CursorKey" }
snapshotId: { type: "ID", defaultValue: null }
) {
id
transferImpactAssessments(
first: $first
after: $after
filter: { snapshotId: $snapshotId }
)
transferImpactAssessments(first: $first, after: $after)
@connection(
key: "ProcessingActivitiesPage_transferImpactAssessments"
filters: ["filter"]
) {
edges {
node {
@@ -209,43 +184,12 @@ const tiaListPageFragment = graphql`
}
`;
const exportProcessingActivitiesPDFMutation = graphql`
mutation ProcessingActivitiesPageExportPDFMutation(
$input: ExportProcessingActivitiesPDFInput!
) {
exportProcessingActivitiesPDF(input: $input) {
data
}
}
`;
const exportDataProtectionImpactAssessmentsPDFMutation = graphql`
mutation ProcessingActivitiesPageExportDPIAPDFMutation(
$input: ExportDataProtectionImpactAssessmentsPDFInput!
) {
exportDataProtectionImpactAssessmentsPDF(input: $input) {
data
}
}
`;
const exportTransferImpactAssessmentsPDFMutation = graphql`
mutation ProcessingActivitiesPageExportTIAPDFMutation(
$input: ExportTransferImpactAssessmentsPDFInput!
) {
exportTransferImpactAssessmentsPDF(input: $input) {
data
}
}
`;
export default function ProcessingActivitiesPage({
queryRef,
}: ProcessingActivitiesPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<"activities" | "dpia" | "tia">(
"activities",
);
@@ -254,6 +198,17 @@ export default function ProcessingActivitiesPage({
const organization = usePreloadedQuery(processingActivitiesQuery, queryRef);
const paDocument = organization.node.processingActivitiesDocument;
const dpiaDocument = organization.node.dataProtectionImpactAssessmentsDocument;
const tiaDocument = organization.node.transferImpactAssessmentsDocument;
const paDefaultApproverIds = (paDocument?.defaultApprovers ?? []).map(a => a.id);
const dpiaDefaultApproverIds = (dpiaDocument?.defaultApprovers ?? []).map(a => a.id);
const tiaDefaultApproverIds = (tiaDocument?.defaultApprovers ?? []).map(a => a.id);
const goToDocument = (documentId: string) => {
void navigate(`/organizations/${organizationId}/documents/${documentId}`);
};
const {
data: activitiesData,
loadNext: loadNextActivities,
@@ -287,7 +242,6 @@ export default function ProcessingActivitiesPage({
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
ProcessingActivitiesConnectionKey,
{ filter: { snapshotId: snapshotId || null } },
);
const activities
= activitiesData?.processingActivities?.edges?.map(edge => edge.node)
@@ -300,178 +254,24 @@ export default function ProcessingActivitiesPage({
= tiaData?.transferImpactAssessments?.edges?.map(edge => edge.node)
?? [];
const hasAnyAction
= !isSnapshotMode
&& activities.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
const hasAnyAction = activities.some(
({ canUpdate, canDelete }) => canUpdate || canDelete,
);
const canExportPDF = organization.node.canExportProcessingActivities;
const [exportPDF, isExportingPDF] = useMutationWithToasts<{
response: {
exportProcessingActivitiesPDF?: {
data: string;
};
};
variables: {
input: {
organizationId: string;
filter: { snapshotId: string } | null;
};
};
}>(exportProcessingActivitiesPDFMutation, {
successMessage: __("PDF download started."),
errorMessage: __("Failed to generate PDF"),
});
const handleExportPDF = async () => {
await exportPDF({
variables: {
input: {
organizationId: organizationId,
filter: snapshotId ? { snapshotId } : null,
},
},
onCompleted: (data) => {
if (data.exportProcessingActivitiesPDF?.data) {
downloadFile(
data.exportProcessingActivitiesPDF.data,
`processing-activities-${toDateInput(new Date().toISOString())}.pdf`,
);
}
},
});
};
const canExportDPIAPDF
= organization.node.canExportDataProtectionImpactAssessments;
const [exportDPIAPDF, isExportingDPIAPDF] = useMutationWithToasts<{
response: {
exportDataProtectionImpactAssessmentsPDF?: {
data: string;
};
};
variables: {
input: {
organizationId: string;
filter: { snapshotId: string } | null;
};
};
}>(exportDataProtectionImpactAssessmentsPDFMutation, {
successMessage: __("PDF download started."),
errorMessage: __("Failed to generate PDF"),
});
const handleExportDPIAPDF = async () => {
await exportDPIAPDF({
variables: {
input: {
organizationId: organizationId,
filter: snapshotId ? { snapshotId } : null,
},
},
onCompleted: (data) => {
if (data.exportDataProtectionImpactAssessmentsPDF?.data) {
downloadFile(
data.exportDataProtectionImpactAssessmentsPDF.data,
`data-protection-impact-assessments-${toDateInput(new Date().toISOString())}.pdf`,
);
}
},
});
};
const canExportTIAPDF
= organization.node.canExportTransferImpactAssessments;
const [exportTIAPDF, isExportingTIAPDF] = useMutationWithToasts<{
response: {
exportTransferImpactAssessmentsPDF?: {
data: string;
};
};
variables: {
input: {
organizationId: string;
filter: { snapshotId: string } | null;
};
};
}>(exportTransferImpactAssessmentsPDFMutation, {
successMessage: __("PDF download started."),
errorMessage: __("Failed to generate PDF"),
});
const handleExportTIAPDF = async () => {
await exportTIAPDF({
variables: {
input: {
organizationId: organizationId,
filter: snapshotId ? { snapshotId } : null,
},
},
onCompleted: (data) => {
if (data.exportTransferImpactAssessmentsPDF?.data) {
downloadFile(
data.exportTransferImpactAssessmentsPDF.data,
`transfer-impact-assessments-${toDateInput(new Date().toISOString())}.pdf`,
);
}
},
});
};
const canPublishProcessingActivities
= organization.node.canPublishProcessingActivities;
const canPublishDPIA
= organization.node.canPublishDataProtectionImpactAssessments;
const canPublishTIA
= organization.node.canPublishTransferImpactAssessments;
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<PageHeader
title={__("Processing Activities")}
description={__("Manage your processing activities under GDPR")}
>
{(canExportPDF || canExportDPIAPDF || canExportTIAPDF) && (
<Dropdown
toggle={(
<Button
variant="secondary"
icon={IconArrowDown}
iconAfter={IconChevronDown}
>
{__("Export")}
</Button>
)}
>
{canExportPDF && (
<DropdownItem
onClick={() => void handleExportPDF()}
disabled={isExportingPDF}
icon={isExportingPDF ? Spinner : undefined}
>
{__("Processing Activities")}
</DropdownItem>
)}
{canExportDPIAPDF && (
<DropdownItem
onClick={() => void handleExportDPIAPDF()}
disabled={isExportingDPIAPDF}
icon={isExportingDPIAPDF ? Spinner : undefined}
>
{__("Data Protection Impact Assessments")}
</DropdownItem>
)}
{canExportTIAPDF && (
<DropdownItem
onClick={() => void handleExportTIAPDF()}
disabled={isExportingTIAPDF}
icon={isExportingTIAPDF ? Spinner : undefined}
>
{__("Transfer Impact Assessments")}
</DropdownItem>
)}
</Dropdown>
)}
{!isSnapshotMode
&& activeTab === "activities"
{activeTab === "activities"
&& organization.node.canCreateProcessingActivity && (
<CreateProcessingActivityDialog
organizationId={organizationId}
@@ -505,6 +305,84 @@ export default function ProcessingActivitiesPage({
</TabItem>
</Tabs>
<div className="flex justify-end gap-2">
{activeTab === "activities" && (
<>
{paDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${paDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{canPublishProcessingActivities && (
<PublishProcessingActivityListDialog
organizationId={organizationId}
defaultApproverIds={paDefaultApproverIds}
onPublished={goToDocument}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishProcessingActivityListDialog>
)}
</>
)}
{activeTab === "dpia" && (
<>
{dpiaDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${dpiaDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{canPublishDPIA && (
<PublishDataProtectionImpactAssessmentListDialog
organizationId={organizationId}
defaultApproverIds={dpiaDefaultApproverIds}
onPublished={goToDocument}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishDataProtectionImpactAssessmentListDialog>
)}
</>
)}
{activeTab === "tia" && (
<>
{tiaDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${tiaDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{canPublishTIA && (
<PublishTransferImpactAssessmentListDialog
organizationId={organizationId}
defaultApproverIds={tiaDefaultApproverIds}
onPublished={goToDocument}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishTransferImpactAssessmentListDialog>
)}
</>
)}
</div>
{activeTab === "activities" && (
<>
{activities.length > 0
@@ -706,8 +584,6 @@ function ActivityRow({
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const [deleteActivity] = useMutation<ProcessingActivityGraphDeleteMutation>(deleteProcessingActivityMutation);
const confirm = useConfirm();
@@ -734,9 +610,7 @@ function ActivityRow({
};
const activityUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/processing-activities/${activity.id}`
: `/organizations/${organizationId}/processing-activities/${activity.id}`;
= `/organizations/${organizationId}/processing-activities/${activity.id}`;
return (
<Tr to={activityUrl}>
@@ -790,13 +664,9 @@ function DPIARow({
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const activityUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/processing-activities/${dpia.processingActivity.id}#dpia`
: `/organizations/${organizationId}/processing-activities/${dpia.processingActivity.id}#dpia`;
= `/organizations/${organizationId}/processing-activities/${dpia.processingActivity.id}#dpia`;
return (
<Tr to={activityUrl}>
@@ -848,13 +718,9 @@ function TIARow({
>;
}) {
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const activityUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/processing-activities/${tia.processingActivity.id}#tia`
: `/organizations/${organizationId}/processing-activities/${tia.processingActivity.id}#tia`;
= `/organizations/${organizationId}/processing-activities/${tia.processingActivity.id}#tia`;
return (
<Tr to={activityUrl}>

View File

@@ -16,7 +16,6 @@ import { formatError, type GraphQLError } from "@probo/helpers";
import {
formatDatetime,
toDateInput,
validateSnapshotConsistency,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
@@ -43,13 +42,11 @@ import {
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { ProcessingActivityGraphNodeQuery } from "#/__generated__/core/ProcessingActivityGraphNodeQuery.graphql";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
@@ -121,8 +118,6 @@ export default function ProcessingActivityDetailsPage(props: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
// Get initial tab from URL hash
const getInitialTab = (): "overview" | "dpia" | "tia" => {
@@ -156,8 +151,6 @@ export default function ProcessingActivityDetailsPage(props: Props) {
window.location.hash = activeTab === "overview" ? "" : activeTab;
}, [activeTab]);
validateSnapshotConsistency(activity, snapshotId);
const updateActivity = useUpdateProcessingActivity();
const createDPIA = useCreateDataProtectionImpactAssessment();
const updateDPIA = useUpdateDataProtectionImpactAssessment();
@@ -199,7 +192,6 @@ export default function ProcessingActivityDetailsPage(props: Props) {
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
ProcessingActivitiesConnectionKey,
{ filter: { snapshotId: snapshotId || null } },
);
const deleteActivity = useDeleteProcessingActivity(
@@ -425,15 +417,10 @@ export default function ProcessingActivityDetailsPage(props: Props) {
});
const breadcrumbProcessingActivitiesUrl
= isSnapshotMode && snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/processing-activities`
: `/organizations/${organizationId}/processing-activities`;
= `/organizations/${organizationId}/processing-activities`;
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<div className="flex items-center justify-between">
<Breadcrumb
items={[
@@ -444,7 +431,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
{ label: activity.name! },
]}
/>
{!isSnapshotMode && activity.canDelete && (
{activity.canDelete && (
<ActionDropdown>
<DropdownItem onClick={deleteActivity} variant="danger">
{__("Delete")}
@@ -489,7 +476,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
{...register("name")}
error={formState.errors.name?.message}
required
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<div>
@@ -508,7 +495,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
onValueChange={field.onChange}
value={field.value}
className="w-full"
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
>
<RoleOptions />
</Select>
@@ -527,7 +514,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
{...register("purpose")}
placeholder={__("Describe the purpose of processing")}
rows={3}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
</div>
@@ -535,14 +522,14 @@ export default function ProcessingActivityDetailsPage(props: Props) {
label={__("Data Subject Category")}
{...register("dataSubjectCategory")}
placeholder={__("e.g., employees, customers, prospects")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<Field
label={__("Personal Data Category")}
{...register("personalDataCategory")}
placeholder={__("e.g., contact details, financial data")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<div>
@@ -563,7 +550,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
onValueChange={field.onChange}
value={field.value}
className="w-full"
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
>
<SpecialOrCriminalDataOptions />
</Select>
@@ -580,7 +567,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
label={__("Consent Evidence Link")}
{...register("consentEvidenceLink")}
placeholder={__("Link to consent evidence if applicable")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<div>
@@ -599,7 +586,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
onValueChange={field.onChange}
value={field.value}
className="w-full"
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
>
<LawfulBasisOptions />
</Select>
@@ -620,7 +607,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
id="lastReviewDate"
type="date"
{...register("lastReviewDate")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
</div>
@@ -632,7 +619,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
id="nextReviewDate"
type="date"
{...register("nextReviewDate")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
</div>
@@ -641,7 +628,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
control={control}
name="dataProtectionOfficerId"
label={__("Data Protection Officer")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
</div>
@@ -650,14 +637,14 @@ export default function ProcessingActivityDetailsPage(props: Props) {
label={__("Recipients")}
{...register("recipients")}
placeholder={__("Who receives the data")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<Field
label={__("Location")}
{...register("location")}
placeholder={__("Where is the data processed")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<Controller
@@ -670,7 +657,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<Checkbox
checked={field.value ?? false}
onChange={field.onChange}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<span>
{__("Data is transferred internationally")}
@@ -694,7 +681,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
onValueChange={field.onChange}
value={field.value}
className="w-full"
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
>
<TransferSafeguardsOptions />
</Select>
@@ -711,7 +698,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
label={__("Retention Period")}
{...register("retentionPeriod")}
placeholder={__("How long is data retained")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
<div>
@@ -720,7 +707,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
{...register("securityMeasures")}
placeholder={__("Technical and organizational measures")}
rows={3}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
</div>
@@ -740,7 +727,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
onValueChange={field.onChange}
value={field.value}
className="w-full"
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
>
<DataProtectionImpactAssessmentOptions />
</Select>
@@ -772,7 +759,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
onValueChange={field.onChange}
value={field.value}
className="w-full"
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
>
<TransferImpactAssessmentOptions />
</Select>
@@ -796,24 +783,22 @@ export default function ProcessingActivityDetailsPage(props: Props) {
name="vendorIds"
selectedVendors={vendors}
label={__("Vendors")}
disabled={isSnapshotMode || !activity.canUpdate}
disabled={!activity.canUpdate}
/>
{!isSnapshotMode && (
<div className="flex justify-end pt-4">
{activity.canUpdate && (
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? __("Saving...")
: __("Save Changes")}
</Button>
)}
</div>
)}
<div className="flex justify-end pt-4">
{activity.canUpdate && (
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? __("Saving...")
: __("Save Changes")}
</Button>
)}
</div>
</form>
</div>
</Card>
@@ -829,7 +814,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<h2 className="text-xl font-semibold mb-6 text-center">
{__("Data Protection Impact Assessment")}
</h2>
{!isSnapshotMode && activity.canCreateDPIA && (
{activity.canCreateDPIA && (
<Button
variant="primary"
onClick={() => setShowDpiaForm(true)}
@@ -845,8 +830,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<h2 className="text-xl font-semibold">
{__("Data Protection Impact Assessment")}
</h2>
{!isSnapshotMode
&& activity?.dataProtectionImpactAssessment?.id
{activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
&& activity.dataProtectionImpactAssessment.canDelete && (
<Button variant="danger" onClick={deleteDPIA}>
@@ -867,7 +851,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the processing activity and its purpose",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
/>
</div>
@@ -882,7 +866,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Explain why the processing is necessary and proportionate",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
/>
</div>
@@ -897,7 +881,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the potential risks to data subjects",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
/>
</div>
@@ -912,7 +896,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe measures to mitigate the identified risks",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
/>
</div>
@@ -930,7 +914,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
onValueChange={field.onChange}
value={field.value}
className="w-full"
disabled={isSnapshotMode || !canCreateOrUpdateDPIA}
disabled={!canCreateOrUpdateDPIA}
>
<Option value="LOW">{__("Low")}</Option>
<Option value="MEDIUM">{__("Medium")}</Option>
@@ -940,37 +924,35 @@ export default function ProcessingActivityDetailsPage(props: Props) {
/>
</div>
{!isSnapshotMode && (
<div className="flex justify-end gap-3 pt-4">
{(!activity?.dataProtectionImpactAssessment?.id
|| dpiaDeleted) && (
<Button
type="button"
variant="secondary"
onClick={() => setShowDpiaForm(false)}
>
{__("Cancel")}
</Button>
)}
{(activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
? activity.dataProtectionImpactAssessment.canUpdate
: activity.canCreateDPIA) && (
<Button
type="submit"
variant="primary"
disabled={dpiaSubmitting}
>
{dpiaSubmitting
? __("Saving...")
: activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
? __("Update DPIA")
: __("Create DPIA")}
</Button>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-4">
{(!activity?.dataProtectionImpactAssessment?.id
|| dpiaDeleted) && (
<Button
type="button"
variant="secondary"
onClick={() => setShowDpiaForm(false)}
>
{__("Cancel")}
</Button>
)}
{(activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
? activity.dataProtectionImpactAssessment.canUpdate
: activity.canCreateDPIA) && (
<Button
type="submit"
variant="primary"
disabled={dpiaSubmitting}
>
{dpiaSubmitting
? __("Saving...")
: activity?.dataProtectionImpactAssessment?.id
&& !dpiaDeleted
? __("Update DPIA")
: __("Create DPIA")}
</Button>
)}
</div>
</form>
</>
)}
@@ -988,7 +970,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<h2 className="text-xl font-semibold mb-6 text-center">
{__("Transfer Impact Assessment")}
</h2>
{!isSnapshotMode && activity.canCreateTIA && (
{activity.canCreateTIA && (
<Button
variant="primary"
onClick={() => setShowTiaForm(true)}
@@ -1004,8 +986,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
<h2 className="text-xl font-semibold">
{__("Transfer Impact Assessment")}
</h2>
{!isSnapshotMode
&& activity?.transferImpactAssessment?.id
{activity?.transferImpactAssessment?.id
&& !tiaDeleted
&& activity.transferImpactAssessment.canDelete && (
<Button variant="danger" onClick={deleteTIA}>
@@ -1026,7 +1007,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the data subjects involved in the transfer",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
@@ -1041,7 +1022,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the legal mechanism for the transfer (e.g., SCCs, BCRs, adequacy decision)",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
@@ -1054,7 +1035,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe the nature and details of the data transfer",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
@@ -1069,7 +1050,7 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Assess the risks related to the local laws of the destination country",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
@@ -1084,40 +1065,38 @@ export default function ProcessingActivityDetailsPage(props: Props) {
"Describe any supplementary measures taken to ensure adequate protection",
)}
rows={4}
disabled={isSnapshotMode || !canCreateOrUpdateTIA}
disabled={!canCreateOrUpdateTIA}
/>
</div>
{!isSnapshotMode && (
<div className="flex justify-end gap-3 pt-4">
{(!activity?.transferImpactAssessment?.id
|| tiaDeleted) && (
<Button
type="button"
variant="secondary"
onClick={() => setShowTiaForm(false)}
>
{__("Cancel")}
</Button>
)}
{(activity?.transferImpactAssessment?.id && !tiaDeleted
? activity.transferImpactAssessment.canUpdate
: activity.canCreateTIA) && (
<Button
type="submit"
variant="primary"
disabled={tiaSubmitting}
>
{tiaSubmitting
? __("Saving...")
: activity?.transferImpactAssessment?.id
&& !tiaDeleted
? __("Update TIA")
: __("Create TIA")}
</Button>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-4">
{(!activity?.transferImpactAssessment?.id
|| tiaDeleted) && (
<Button
type="button"
variant="secondary"
onClick={() => setShowTiaForm(false)}
>
{__("Cancel")}
</Button>
)}
{(activity?.transferImpactAssessment?.id && !tiaDeleted
? activity.transferImpactAssessment.canUpdate
: activity.canCreateTIA) && (
<Button
type="submit"
variant="primary"
disabled={tiaSubmitting}
>
{tiaSubmitting
? __("Saving...")
: activity?.transferImpactAssessment?.id
&& !tiaDeleted
? __("Update TIA")
: __("Create TIA")}
</Button>
)}
</div>
</form>
</>
)}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
IconSend,
IconUpload,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishDataProtectionImpactAssessmentListDialogMutation } from "#/__generated__/core/PublishDataProtectionImpactAssessmentListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishDataProtectionImpactAssessmentListDialogMutation(
$input: PublishDataProtectionImpactAssessmentListInput!
) {
publishDataProtectionImpactAssessmentList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishDataProtectionImpactAssessmentListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const schema = useMemo(() => z.object({
approverIds: z.array(z.string()),
}), []);
const {
control,
handleSubmit,
reset,
watch,
} = useFormWithSchema(schema, {
defaultValues: {
approverIds: defaultApproverIds ?? [],
},
});
const [publish, isPublishing]
= useMutation<PublishDataProtectionImpactAssessmentListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishDataProtectionImpactAssessmentList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Data Protection Impact Assessments published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish Data Protection Impact Assessments"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Data Protection Impact Assessments")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
IconSend,
IconUpload,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishProcessingActivityListDialogMutation } from "#/__generated__/core/PublishProcessingActivityListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishProcessingActivityListDialogMutation(
$input: PublishProcessingActivityListInput!
) {
publishProcessingActivityList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishProcessingActivityListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const schema = useMemo(() => z.object({
approverIds: z.array(z.string()),
}), []);
const {
control,
handleSubmit,
reset,
watch,
} = useFormWithSchema(schema, {
defaultValues: {
approverIds: defaultApproverIds ?? [],
},
});
const [publish, isPublishing]
= useMutation<PublishProcessingActivityListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishProcessingActivityList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Processing activities published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish processing activities"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Processing Activities")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
IconSend,
IconUpload,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishTransferImpactAssessmentListDialogMutation } from "#/__generated__/core/PublishTransferImpactAssessmentListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishTransferImpactAssessmentListDialogMutation(
$input: PublishTransferImpactAssessmentListInput!
) {
publishTransferImpactAssessmentList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishTransferImpactAssessmentListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const schema = useMemo(() => z.object({
approverIds: z.array(z.string()),
}), []);
const {
control,
handleSubmit,
reset,
watch,
} = useFormWithSchema(schema, {
defaultValues: {
approverIds: defaultApproverIds ?? [],
},
});
const [publish, isPublishing]
= useMutation<PublishTransferImpactAssessmentListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishTransferImpactAssessmentList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Transfer Impact Assessments published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish Transfer Impact Assessments"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Transfer Impact Assessments")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}