From 25377668205dd231e17863dbd9b87cfa3484f15c Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 18 Jun 2026 12:27:38 +0200 Subject: [PATCH 1/3] Add GDPR removal customer detail extension to anonymizer Adds a "GDPR removal" action mounted on the Dashboard customer detail page (CUSTOMER_DETAILS_MORE_ACTIONS, POPUP). The popup resolves the customer by the id the Dashboard passes, then runs a staged flow: scan orders/checkouts/gift cards, preview the exact list, confirm via a red gate, execute the existing anonymize/delete mutations (customer deleted last), and re-scan to verify with a per-type pass/fail summary. Staff accounts are blocked. Reuses the existing pure helpers and GraphQL documents; the email-based single-customer page is left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/gdpr-removal-customer-extension.md | 5 + apps/anonymizer/generated/graphql.ts | 19 + .../graphql/queries/customer-by-id.graphql | 20 + .../anonymize/gdpr-removal/customer.test.ts | 33 ++ .../anonymize/gdpr-removal/customer.ts | 31 ++ .../gdpr-removal/gdpr-removal-view.tsx | 510 ++++++++++++++++++ .../gdpr-removal/verification.test.ts | 65 +++ .../anonymize/gdpr-removal/verification.ts | 105 ++++ apps/anonymizer/src/pages/_app.tsx | 2 +- apps/anonymizer/src/pages/api/manifest.ts | 19 + apps/anonymizer/src/pages/gdpr-removal.tsx | 50 ++ 11 files changed, 858 insertions(+), 1 deletion(-) create mode 100644 .changeset/gdpr-removal-customer-extension.md create mode 100644 apps/anonymizer/graphql/queries/customer-by-id.graphql create mode 100644 apps/anonymizer/src/modules/anonymize/gdpr-removal/customer.test.ts create mode 100644 apps/anonymizer/src/modules/anonymize/gdpr-removal/customer.ts create mode 100644 apps/anonymizer/src/modules/anonymize/gdpr-removal/gdpr-removal-view.tsx create mode 100644 apps/anonymizer/src/modules/anonymize/gdpr-removal/verification.test.ts create mode 100644 apps/anonymizer/src/modules/anonymize/gdpr-removal/verification.ts create mode 100644 apps/anonymizer/src/pages/gdpr-removal.tsx diff --git a/.changeset/gdpr-removal-customer-extension.md b/.changeset/gdpr-removal-customer-extension.md new file mode 100644 index 000000000..063a998b0 --- /dev/null +++ b/.changeset/gdpr-removal-customer-extension.md @@ -0,0 +1,5 @@ +--- +"saleor-app-anonymizer": minor +--- + +Added a "GDPR removal" action to the customer detail page in the Dashboard. Before, erasing a customer required opening the app and typing their email by hand. Now you can open the action directly from a customer, scan their orders, checkouts and gift cards, review the exact list, and — after a red confirmation — anonymize the orders and delete the checkouts, gift cards and account in one step. The flow then re-checks the store and shows a per-type pass/fail summary so you can confirm nothing was left behind. Staff accounts are blocked from removal. diff --git a/apps/anonymizer/generated/graphql.ts b/apps/anonymizer/generated/graphql.ts index 7552d27ac..0a81a6c7a 100644 --- a/apps/anonymizer/generated/graphql.ts +++ b/apps/anonymizer/generated/graphql.ts @@ -2330,6 +2330,13 @@ export type CheckoutsForDeletionQueryVariables = Exact<{ export type CheckoutsForDeletionQuery = { readonly checkouts?: { readonly pageInfo: { readonly hasNextPage: boolean, readonly endCursor?: string | null }, readonly edges: ReadonlyArray<{ readonly node: { readonly id: string, readonly email?: string | null } }> } | null }; +export type CustomerByIdQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type CustomerByIdQuery = { readonly user?: { readonly id: string, readonly email: string, readonly firstName: string, readonly lastName: string, readonly isStaff: boolean } | null }; + export type CustomersForDeletionQueryVariables = Exact<{ after?: InputMaybe; }>; @@ -2469,6 +2476,17 @@ export const UntypedCheckoutsForDeletionDocument = gql` } } `; +export const UntypedCustomerByIdDocument = gql` + query CustomerById($id: ID!) { + user(id: $id) { + id + email + firstName + lastName + isStaff + } +} + `; export const UntypedCustomersForDeletionDocument = gql` query CustomersForDeletion($after: String) { customers( @@ -2591,6 +2609,7 @@ export const GiftCardBulkDeleteDocument = {"kind":"Document","definitions":[{"ki export const OrderUpdateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"OrderUpdate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orderUpdate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"field"}},{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMetadata"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"MetadataInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMetadata"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"field"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"code"}}]}}]}}]}}]} as unknown as DocumentNode; export const CheckoutsForDeletionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CheckoutsForDeletion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkouts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"field"},"value":{"kind":"EnumValue","value":"CREATION_DATE"}},{"kind":"ObjectField","name":{"kind":"Name","value":"direction"},"value":{"kind":"EnumValue","value":"ASC"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const CustomerByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CustomerById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"isStaff"}}]}}]}}]} as unknown as DocumentNode; export const CustomersForDeletionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CustomersForDeletion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"field"},"value":{"kind":"EnumValue","value":"CREATED_AT"}},{"kind":"ObjectField","name":{"kind":"Name","value":"direction"},"value":{"kind":"EnumValue","value":"ASC"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"isStaff"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GiftCardsForDeletionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GiftCardsForDeletion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"giftCards"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"field"},"value":{"kind":"EnumValue","value":"CREATED_AT"}},{"kind":"ObjectField","name":{"kind":"Name","value":"direction"},"value":{"kind":"EnumValue","value":"ASC"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdByEmail"}},{"kind":"Field","name":{"kind":"Name","value":"usedByEmail"}},{"kind":"Field","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"currentBalance"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"amount"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const OrdersForAnonymizationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OrdersForAnonymization"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"sortBy"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"field"},"value":{"kind":"EnumValue","value":"NUMBER"}},{"kind":"ObjectField","name":{"kind":"Name","value":"direction"},"value":{"kind":"EnumValue","value":"ASC"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"metadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"shippingAddress"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AnonymizationAddress"}}]}},{"kind":"Field","name":{"kind":"Name","value":"billingAddress"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AnonymizationAddress"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AnonymizationAddress"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Address"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"city"}},{"kind":"Field","name":{"kind":"Name","value":"postalCode"}},{"kind":"Field","name":{"kind":"Name","value":"streetAddress1"}},{"kind":"Field","name":{"kind":"Name","value":"countryArea"}},{"kind":"Field","name":{"kind":"Name","value":"country"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"code"}}]}}]}}]} as unknown as DocumentNode; diff --git a/apps/anonymizer/graphql/queries/customer-by-id.graphql b/apps/anonymizer/graphql/queries/customer-by-id.graphql new file mode 100644 index 000000000..c1b7fa6c8 --- /dev/null +++ b/apps/anonymizer/graphql/queries/customer-by-id.graphql @@ -0,0 +1,20 @@ +# Resolves a customer by the ID the Dashboard passes to the GDPR Removal +# extension (mounted on CUSTOMER_DETAILS_MORE_ACTIONS). Everything else in this +# app is email-based, so the GDPR flow reads the customer's `email` here and then +# reuses the existing email-filtered queries (orders, checkouts, gift cards). +# +# `User.orders` is intentionally NOT selected: it requires MANAGE_STAFF/OWNER, +# which this app does not have - orders are fetched separately via the top-level +# `orders` query filtered by the resolved email (see UserByEmail). +# +# `isStaff` is read so the UI can refuse to remove staff accounts: this action is +# reachable from any customer detail page, including staff members'. +query CustomerById($id: ID!) { + user(id: $id) { + id + email + firstName + lastName + isStaff + } +} diff --git a/apps/anonymizer/src/modules/anonymize/gdpr-removal/customer.test.ts b/apps/anonymizer/src/modules/anonymize/gdpr-removal/customer.test.ts new file mode 100644 index 000000000..861494ef4 --- /dev/null +++ b/apps/anonymizer/src/modules/anonymize/gdpr-removal/customer.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { formatCustomerName, isStaffAccount } from "./customer"; + +describe("isStaffAccount", () => { + it("blocks staff accounts", () => { + expect(isStaffAccount({ isStaff: true })).toBe(true); + }); + + it("allows customer accounts", () => { + expect(isStaffAccount({ isStaff: false })).toBe(false); + }); +}); + +describe("formatCustomerName", () => { + it("joins first and last name", () => { + expect( + formatCustomerName({ firstName: "Ada", lastName: "Lovelace", email: "ada@example.com" }), + ).toBe("Ada Lovelace"); + }); + + it("falls back to the email when the name is empty", () => { + expect(formatCustomerName({ firstName: "", lastName: "", email: "guest@example.com" })).toBe( + "guest@example.com", + ); + }); + + it("trims when only one name part is present", () => { + expect(formatCustomerName({ firstName: "Ada", lastName: "", email: "ada@example.com" })).toBe( + "Ada", + ); + }); +}); diff --git a/apps/anonymizer/src/modules/anonymize/gdpr-removal/customer.ts b/apps/anonymizer/src/modules/anonymize/gdpr-removal/customer.ts new file mode 100644 index 000000000..71e10147f --- /dev/null +++ b/apps/anonymizer/src/modules/anonymize/gdpr-removal/customer.ts @@ -0,0 +1,31 @@ +/** + * The shape of a customer resolved from the ID the Dashboard passes to the GDPR + * Removal extension (see the `CustomerById` query). + */ +export type ResolvedCustomer = { + id: string; + email: string; + firstName: string; + lastName: string; + isStaff: boolean; +}; + +/** + * Staff accounts must never be removed through this tool. The extension is + * mounted on the customer detail page, which also renders for staff members, so + * the flow checks this before fetching or mutating anything and refuses to + * proceed for a staff account. + */ +export const isStaffAccount = (customer: Pick) => customer.isStaff; + +/** + * Human-readable customer label for the popup header, falling back to the email + * when the name fields are empty (e.g. guest-only or already-anonymized users). + */ +export const formatCustomerName = ( + customer: Pick, +) => { + const name = `${customer.firstName} ${customer.lastName}`.trim(); + + return name || customer.email; +}; diff --git a/apps/anonymizer/src/modules/anonymize/gdpr-removal/gdpr-removal-view.tsx b/apps/anonymizer/src/modules/anonymize/gdpr-removal/gdpr-removal-view.tsx new file mode 100644 index 000000000..85edb33e8 --- /dev/null +++ b/apps/anonymizer/src/modules/anonymize/gdpr-removal/gdpr-removal-view.tsx @@ -0,0 +1,510 @@ +import { Box, Button, Text } from "@saleor/macaw-ui"; +import { useState } from "react"; +import { CombinedError, type OperationResult, useClient } from "urql"; + +import { env } from "@/env"; +import { + CheckoutDeleteDocument, + CheckoutsForDeletionDocument, + type CheckoutsForDeletionQuery, + CustomerByIdDocument, + CustomerDeleteDocument, + GiftCardBulkDeleteDocument, + GiftCardsForDeletionDocument, + type GiftCardsForDeletionQuery, + OrderUpdateDocument, + UserByEmailDocument, + type UserByEmailQuery, +} from "@/generated/graphql"; +import { createLogger } from "@/logger"; + +import { chunkArray } from "../bulk-anonymize"; +import { checkoutMatchesEmail } from "../checkouts"; +import { ConfirmationModal } from "../confirmation-modal"; +import { aggregateBalancesByCurrency, formatBalances, giftCardMatchesEmail } from "../gift-cards"; +import { scrambleAddress, scrambleUserDetails } from "../scramble"; +import { useCheckoutDeletionSupport } from "../use-checkout-deletion-support"; +import { formatCustomerName, isStaffAccount, type ResolvedCustomer } from "./customer"; +import { + buildVerificationSummary, + isRemovalFullyVerified, + type VerificationLine, +} from "./verification"; + +const logger = createLogger("GdprRemovalView"); + +type OrderNode = NonNullable["edges"][number]["node"]; +type GiftCardNode = NonNullable["edges"][number]["node"]; + +type ScannedData = { + customer: ResolvedCustomer; + orders: OrderNode[]; + checkoutIds: string[]; + giftCards: GiftCardNode[]; +}; + +/** Max gift card ids per `giftCardBulkDelete` call (mirrors the gift cards section). */ +const GIFT_CARD_DELETE_BATCH_SIZE = 100; + +type Stage = "idle" | "scanning" | "blocked" | "preview" | "executing" | "verifying" | "done"; + +/** + * Walks every page of the customer's orders. Orders are read via the top-level + * `orders` query filtered by email (not `User.orders`, which needs MANAGE_STAFF + * this app lacks), which also covers guest-checkout orders sharing the email. + */ +const scanOrdersByEmail = async ( + client: ReturnType, + email: string, +): Promise => { + const orders: OrderNode[] = []; + let after: string | null = null; + + do { + const result: OperationResult = await client + .query(UserByEmailDocument, { email, after }, { requestPolicy: "network-only" }) + .toPromise(); + + if (result.error) { + throw result.error; + } + + const connection = result.data?.orders; + + if (!connection) { + break; + } + + orders.push(...connection.edges.map((edge) => edge.node)); + + after = connection.pageInfo.hasNextPage ? connection.pageInfo.endCursor ?? null : null; + } while (after); + + return orders; +}; + +/** + * Walks every checkout and matches the customer email client-side. Saleor has no + * exact server-side filter on a checkout's own email, and guest checkouts are + * not cascade-deleted with the customer, so they must be found and deleted here. + */ +const scanCheckoutIdsByEmail = async ( + client: ReturnType, + email: string, +): Promise => { + const ids: string[] = []; + let after: string | null = null; + + do { + const result: OperationResult = await client + .query(CheckoutsForDeletionDocument, { after }, { requestPolicy: "network-only" }) + .toPromise(); + + if (result.error) { + throw result.error; + } + + const connection = result.data?.checkouts; + + if (!connection) { + break; + } + + for (const { node } of connection.edges) { + if (checkoutMatchesEmail(node, email)) { + ids.push(node.id); + } + } + + after = connection.pageInfo.hasNextPage ? connection.pageInfo.endCursor ?? null : null; + } while (after); + + return ids; +}; + +/** + * Walks every gift card and matches the customer email (createdBy/usedBy/events) + * client-side, since Saleor offers no server-side filter covering all three. + */ +const scanGiftCardsByEmail = async ( + client: ReturnType, + email: string, +): Promise => { + const cards: GiftCardNode[] = []; + let after: string | null = null; + + do { + const result: OperationResult = await client + .query(GiftCardsForDeletionDocument, { after }, { requestPolicy: "network-only" }) + .toPromise(); + + if (result.error) { + throw result.error; + } + + const connection = result.data?.giftCards; + + if (!connection) { + break; + } + + for (const { node } of connection.edges) { + if (giftCardMatchesEmail(node, email)) { + cards.push(node); + } + } + + after = connection.pageInfo.hasNextPage ? connection.pageInfo.endCursor ?? null : null; + } while (after); + + return cards; +}; + +/** + * The GDPR Removal popup, opened from a customer's detail page. Staged flow: + * scan -> review the exact list -> confirm (red) -> execute -> verify by + * re-scanning. The customer id is supplied by the Dashboard as a query param. + */ +export const GdprRemovalView = ({ customerId }: { customerId: string }) => { + const client = useClient(); + const { supported: checkoutDeletionSupported, fetching: versionFetching } = + useCheckoutDeletionSupport(); + + const [stage, setStage] = useState("idle"); + const [error, setError] = useState(null); + const [staffEmail, setStaffEmail] = useState(null); + const [scanned, setScanned] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [executionFailures, setExecutionFailures] = useState([]); + const [verification, setVerification] = useState(null); + + const resolveCustomer = async (): Promise => { + const result = await client + .query(CustomerByIdDocument, { id: customerId }, { requestPolicy: "network-only" }) + .toPromise(); + + if (result.error) { + throw result.error; + } + + return result.data?.user ?? null; + }; + + const handleScan = async () => { + setError(null); + setStaffEmail(null); + setScanned(null); + setExecutionFailures([]); + setVerification(null); + setStage("scanning"); + + try { + const customer = await resolveCustomer(); + + if (!customer) { + setError("This customer no longer exists."); + setStage("idle"); + + return; + } + + if (isStaffAccount(customer)) { + setStaffEmail(customer.email); + setStage("blocked"); + + return; + } + + const orders = await scanOrdersByEmail(client, customer.email); + // Only stores on Saleor >= 3.23 can delete checkouts; below that they are skipped. + const checkoutIds = checkoutDeletionSupported + ? await scanCheckoutIdsByEmail(client, customer.email) + : []; + const giftCards = await scanGiftCardsByEmail(client, customer.email); + + setScanned({ customer, orders, checkoutIds, giftCards }); + setStage("preview"); + } catch (caughtError) { + logger.error("Failed to scan customer data", { error: caughtError }); + + const message = + caughtError instanceof CombinedError + ? caughtError.graphQLErrors.map((graphQLError) => graphQLError.message).join(" ") || + caughtError.message + : "Failed to scan customer data. Check the app permissions and try again."; + + setError(message); + setStage("idle"); + } + }; + + /** Runs all removal mutations, customer account deleted last. */ + const executeRemoval = async (data: ScannedData): Promise => { + const failures: string[] = []; + const scrambledUser = scrambleUserDetails(env.NEXT_PUBLIC_CUSTOMER_SCRAMBLE_DOMAIN); + + for (const order of data.orders) { + const result = await client + .mutation(OrderUpdateDocument, { + id: order.id, + input: { + userEmail: scrambledUser.email, + billingAddress: scrambleAddress(order.billingAddress), + shippingAddress: scrambleAddress(order.shippingAddress), + }, + }) + .toPromise(); + + if (result.error || result.data?.orderUpdate?.errors?.length) { + logger.error("Failed to update order", { + orderNumber: order.number, + error: result.error, + mutationErrors: result.data?.orderUpdate?.errors, + }); + failures.push(`Failed to anonymize order #${order.number}`); + } + } + + for (const checkoutId of data.checkoutIds) { + const result = await client.mutation(CheckoutDeleteDocument, { id: checkoutId }).toPromise(); + + if (result.error || result.data?.checkoutDelete?.errors?.length) { + logger.error("Failed to delete checkout", { + checkoutId, + error: result.error, + mutationErrors: result.data?.checkoutDelete?.errors, + }); + failures.push(`Failed to delete a checkout (${checkoutId}).`); + } + } + + for (const batch of chunkArray(data.giftCards, GIFT_CARD_DELETE_BATCH_SIZE)) { + const result = await client + .mutation(GiftCardBulkDeleteDocument, { ids: batch.map((card) => card.id) }) + .toPromise(); + + const mutationErrors = result.data?.giftCardBulkDelete?.errors ?? []; + + if (result.error || mutationErrors.length) { + logger.error("Failed to delete gift cards", { + error: result.error, + mutationErrors: mutationErrors.map((mutationError) => mutationError.message), + }); + failures.push("Failed to delete some gift cards."); + } + } + + /* + * Delete the account last and only when everything before it succeeded, so a + * partial failure leaves the customer in place for the operator to re-scan + * and retry rather than orphaning still-identifiable orders. + */ + if (!failures.length) { + const result = await client + .mutation(CustomerDeleteDocument, { id: data.customer.id }) + .toPromise(); + + if (result.error || result.data?.customerDelete?.errors?.length) { + logger.error("Failed to delete the customer", { + error: result.error, + mutationErrors: result.data?.customerDelete?.errors, + }); + failures.push("Failed to delete the customer account."); + } + } + + return failures; + }; + + /** Re-scans by the original email and asserts nothing remains. */ + const verifyRemoval = async (data: ScannedData): Promise => { + const { email } = data.customer; + + const remainingCustomer = await resolveCustomer(); + const remainingOrders = await scanOrdersByEmail(client, email); + const remainingCheckouts = checkoutDeletionSupported + ? await scanCheckoutIdsByEmail(client, email) + : []; + const remainingGiftCards = await scanGiftCardsByEmail(client, email); + + return buildVerificationSummary( + { + remainingOrders: remainingOrders.length, + remainingCheckouts: remainingCheckouts.length, + remainingGiftCards: remainingGiftCards.length, + customerStillExists: remainingCustomer !== null, + }, + { checkoutsApplicable: checkoutDeletionSupported, hadCustomerAccount: true }, + ); + }; + + const handleConfirm = async () => { + if (!scanned) { + return; + } + + setConfirmOpen(false); + setError(null); + setStage("executing"); + + try { + const failures = await executeRemoval(scanned); + + setExecutionFailures(failures); + setStage("verifying"); + + const lines = await verifyRemoval(scanned); + + setVerification(lines); + setStage("done"); + } catch (caughtError) { + logger.error("GDPR removal failed", { error: caughtError }); + setError("Removal failed unexpectedly. Re-scan to check the current state and retry."); + setStage("idle"); + } + }; + + const giftCardBalances = scanned ? aggregateBalancesByCurrency(scanned.giftCards) : []; + + return ( + + {error && ( + + {error} + + )} + + {stage === "blocked" && ( + + + This is a staff account and cannot be removed here. + + + {staffEmail} is a staff member. GDPR removal is only available for customer accounts. + + + )} + + {stage === "idle" && ( + + + Scan this customer's orders, checkouts and gift cards before removing them. Nothing + is changed until you confirm. + + + + )} + + {stage === "scanning" && Scanning orders, checkouts and gift cards…} + + {stage === "preview" && scanned && ( + + + Removing {formatCustomerName(scanned.customer)} ( + {scanned.customer.email}) will anonymize or delete the following. This cannot be undone. + + + + {`Orders to anonymize: ${scanned.orders.length}`} + {scanned.orders.length > 0 && ( + + {scanned.orders.map((order) => ( +
  • + {`Order #${order.number}`} +
  • + ))} +
    + )} +
    + + + {`Checkouts to delete: ${scanned.checkoutIds.length}`} + {!checkoutDeletionSupported && ( + + Checkout deletion requires Saleor 3.23+ and is skipped on this store. + + )} + + + + {`Gift cards to delete: ${scanned.giftCards.length}`} + {giftCardBalances.length > 0 && ( + + {`Balance permanently destroyed: ${formatBalances(giftCardBalances)}.`} + + )} + + + + The customer account will be permanently deleted. + + + + + + +
    + )} + + {stage === "executing" && Removing customer data…} + {stage === "verifying" && Verifying removal…} + + {stage === "done" && verification && ( + + + {isRemovalFullyVerified(verification) + ? "GDPR removal complete and verified." + : "GDPR removal finished with issues — see below."} + + + {verification.map((line) => ( +
  • + + {`${line.status === "ok" ? "✓" : line.status === "skipped" ? "–" : "✗"} ${ + line.label + }`} + +
  • + ))} +
    + {executionFailures.length > 0 && ( + + {executionFailures.map((failure, index) => ( + + {failure} + + ))} + + )} + + + +
    + )} + + setConfirmOpen(false)} + onConfirm={handleConfirm} + /> +
    + ); +}; diff --git a/apps/anonymizer/src/modules/anonymize/gdpr-removal/verification.test.ts b/apps/anonymizer/src/modules/anonymize/gdpr-removal/verification.test.ts new file mode 100644 index 000000000..26ef080da --- /dev/null +++ b/apps/anonymizer/src/modules/anonymize/gdpr-removal/verification.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { buildVerificationSummary, isRemovalFullyVerified } from "./verification"; + +const allRemoved = { + remainingOrders: 0, + remainingCheckouts: 0, + remainingGiftCards: 0, + customerStillExists: false, +}; + +const fullScope = { checkoutsApplicable: true, hadCustomerAccount: true }; + +describe("buildVerificationSummary", () => { + it("marks every line ok when nothing remains", () => { + const lines = buildVerificationSummary(allRemoved, fullScope); + + expect(lines.map((line) => line.status)).toStrictEqual(["ok", "ok", "ok", "ok"]); + expect(isRemovalFullyVerified(lines)).toBe(true); + }); + + it("flags the types that still have leftover data", () => { + const lines = buildVerificationSummary( + { + remainingOrders: 2, + remainingCheckouts: 0, + remainingGiftCards: 1, + customerStillExists: true, + }, + fullScope, + ); + + const byType = Object.fromEntries(lines.map((line) => [line.type, line.status])); + + expect(byType).toStrictEqual({ + orders: "failed", + checkouts: "ok", + giftCards: "failed", + customer: "failed", + }); + expect(isRemovalFullyVerified(lines)).toBe(false); + }); + + it("reports checkouts as skipped (not failed) when checkout deletion is unsupported", () => { + const lines = buildVerificationSummary( + { ...allRemoved, remainingCheckouts: 5 }, + { checkoutsApplicable: false, hadCustomerAccount: true }, + ); + + const checkouts = lines.find((line) => line.type === "checkouts"); + + expect(checkouts?.status).toBe("skipped"); + // A skipped check must not fail overall verification. + expect(isRemovalFullyVerified(lines)).toBe(true); + }); + + it("omits the customer line when there was no account", () => { + const lines = buildVerificationSummary(allRemoved, { + checkoutsApplicable: true, + hadCustomerAccount: false, + }); + + expect(lines.some((line) => line.type === "customer")).toBe(false); + }); +}); diff --git a/apps/anonymizer/src/modules/anonymize/gdpr-removal/verification.ts b/apps/anonymizer/src/modules/anonymize/gdpr-removal/verification.ts new file mode 100644 index 000000000..e0e8605ac --- /dev/null +++ b/apps/anonymizer/src/modules/anonymize/gdpr-removal/verification.ts @@ -0,0 +1,105 @@ +/** + * Pure helpers for the GDPR Removal flow's post-removal verification step. + * + * After the mutations run, the flow re-scans the store by the customer's + * original email and feeds the counts here. Because the orders are scrambled + * (their `userEmail` is overwritten), a successfully-anonymized order no longer + * matches the original email - so "0 orders remain linked to this email" is the + * success signal for anonymization, even though the order rows still exist. + */ + +export type RemovalScope = { + /** + * Whether checkout deletion was attempted at all. `checkoutDelete` requires + * Saleor >= 3.23; below that the checkouts line is reported as skipped rather + * than failed, so an old store does not show a false red mark. + */ + checkoutsApplicable: boolean; + /** Whether there was a customer account to delete (always true via this flow). */ + hadCustomerAccount: boolean; +}; + +export type VerificationCounts = { + /** Orders still matching the original email after re-scan. */ + remainingOrders: number; + /** Checkouts still matching the original email after re-scan. */ + remainingCheckouts: number; + /** Gift cards still matching the original email after re-scan. */ + remainingGiftCards: number; + /** Whether `user(id:)` still resolves the account after re-scan. */ + customerStillExists: boolean; +}; + +export type VerificationStatus = "ok" | "failed" | "skipped"; + +export type VerificationLine = { + type: "orders" | "checkouts" | "giftCards" | "customer"; + status: VerificationStatus; + label: string; +}; + +/** + * Turns the re-scan counts into a per-type pass/fail summary. Read-only: there + * is no retry, so a mismatch just surfaces what is left behind for the operator + * to re-scan (the flow is idempotent). + */ +export const buildVerificationSummary = ( + counts: VerificationCounts, + scope: RemovalScope, +): VerificationLine[] => { + const lines: VerificationLine[] = []; + + lines.push({ + type: "orders", + status: counts.remainingOrders === 0 ? "ok" : "failed", + label: + counts.remainingOrders === 0 + ? "Orders anonymized — none remain linked to this email" + : `${counts.remainingOrders} order(s) still linked to this email`, + }); + + if (scope.checkoutsApplicable) { + lines.push({ + type: "checkouts", + status: counts.remainingCheckouts === 0 ? "ok" : "failed", + label: + counts.remainingCheckouts === 0 + ? "Checkouts deleted" + : `${counts.remainingCheckouts} checkout(s) still present`, + }); + } else { + lines.push({ + type: "checkouts", + status: "skipped", + label: "Checkout deletion requires Saleor 3.23+ — skipped", + }); + } + + lines.push({ + type: "giftCards", + status: counts.remainingGiftCards === 0 ? "ok" : "failed", + label: + counts.remainingGiftCards === 0 + ? "Gift cards deleted" + : `${counts.remainingGiftCards} gift card(s) still present`, + }); + + if (scope.hadCustomerAccount) { + lines.push({ + type: "customer", + status: counts.customerStillExists ? "failed" : "ok", + label: counts.customerStillExists + ? "Customer account still exists" + : "Customer account deleted", + }); + } + + return lines; +}; + +/** + * True when every applicable check passed (skipped lines do not count as + * failures). Drives the green/red header on the result screen. + */ +export const isRemovalFullyVerified = (lines: ReadonlyArray) => + lines.every((line) => line.status !== "failed"); diff --git a/apps/anonymizer/src/pages/_app.tsx b/apps/anonymizer/src/pages/_app.tsx index 53ceb4a58..48d73c35c 100644 --- a/apps/anonymizer/src/pages/_app.tsx +++ b/apps/anonymizer/src/pages/_app.tsx @@ -21,7 +21,7 @@ function NextApp({ Component, pageProps }: AppProps) { } > diff --git a/apps/anonymizer/src/pages/api/manifest.ts b/apps/anonymizer/src/pages/api/manifest.ts index 366ea23ae..46ee8f739 100644 --- a/apps/anonymizer/src/pages/api/manifest.ts +++ b/apps/anonymizer/src/pages/api/manifest.ts @@ -25,6 +25,25 @@ const handler = wrapWithLoggerContext( }, }, dataPrivacyUrl: "https://saleor.io/legal/privacy/", + extensions: [ + /* + * Adds a "GDPR removal" action to a customer's detail page. The + * Dashboard opens this URL in a modal iframe and appends the + * customer id as the `id` query param. + */ + { + label: "GDPR removal", + mount: "CUSTOMER_DETAILS_MORE_ACTIONS", + target: "POPUP", + permissions: [ + "MANAGE_ORDERS", + "MANAGE_USERS", + "MANAGE_CHECKOUTS", + "MANAGE_GIFT_CARD", + ], + url: `${iframeBaseUrl}/gdpr-removal`, + }, + ], homepageUrl: "https://github.com/saleor/apps", id: env.MANIFEST_APP_ID, name: "Anonymizer", diff --git a/apps/anonymizer/src/pages/gdpr-removal.tsx b/apps/anonymizer/src/pages/gdpr-removal.tsx new file mode 100644 index 000000000..b0575d405 --- /dev/null +++ b/apps/anonymizer/src/pages/gdpr-removal.tsx @@ -0,0 +1,50 @@ +import { Box, Text } from "@saleor/macaw-ui"; +import { type NextPage } from "next"; +import { useRouter } from "next/router"; + +import { GdprRemovalView } from "@/modules/anonymize/gdpr-removal/gdpr-removal-view"; + +/** + * Popup opened from a customer's detail page (CUSTOMER_DETAILS_MORE_ACTIONS). + * The Dashboard renders this route in an iframe and passes the customer id as + * the `id` query parameter. + */ +const GdprRemovalPage: NextPage = () => { + const router = useRouter(); + const { id } = router.query; + const customerId = typeof id === "string" ? id : null; + + return ( + + + + GDPR removal + + + This permanently removes the customer's personal data: their orders are anonymized + (names, phone and street address scrambled), and their checkouts, gift cards and account + are deleted. Deleting a gift card also destroys its remaining balance. These actions + cannot be undone. + + + This covers data in the standard orders, checkouts and gift cards APIs. It does not erase + PII held elsewhere, such as metadata, exports or third-party integrations. + + + + {/* router.query is empty until the router is ready, so wait before reading the id. */} + {!router.isReady ? ( + Loading… + ) : customerId ? ( + + ) : ( + + No customer id was provided. Open this from a customer's detail page in the + Dashboard. + + )} + + ); +}; + +export default GdprRemovalPage; From b3dfbc901cfe03fea53740c0c4e49651020e3ae8 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 18 Jun 2026 19:36:17 +0200 Subject: [PATCH 2/3] Read customer id from `customerId` query param, not `id` The Dashboard's AppBridge reserves the `id` query param for the app's own id (e.g. `App:1076`), so reading `id` resolved the active app instead of the customer. The customer id is passed under `customerId` (e.g. `User:1503`) for CUSTOMER_DETAILS_MORE_ACTIONS extensions. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/anonymizer/src/pages/gdpr-removal.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/anonymizer/src/pages/gdpr-removal.tsx b/apps/anonymizer/src/pages/gdpr-removal.tsx index b0575d405..384633e7f 100644 --- a/apps/anonymizer/src/pages/gdpr-removal.tsx +++ b/apps/anonymizer/src/pages/gdpr-removal.tsx @@ -7,12 +7,13 @@ import { GdprRemovalView } from "@/modules/anonymize/gdpr-removal/gdpr-removal-v /** * Popup opened from a customer's detail page (CUSTOMER_DETAILS_MORE_ACTIONS). * The Dashboard renders this route in an iframe and passes the customer id as - * the `id` query parameter. + * the `customerId` query parameter. Note: the `id` param is reserved by the + * Dashboard for the app's own id (AppBridge), so it must not be used here. */ const GdprRemovalPage: NextPage = () => { const router = useRouter(); - const { id } = router.query; - const customerId = typeof id === "string" ? id : null; + const { customerId: customerIdParam } = router.query; + const customerId = typeof customerIdParam === "string" ? customerIdParam : null; return ( From 29ff900f6d7f551426c7926a586d3dff86a9f383 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Thu, 18 Jun 2026 19:48:39 +0200 Subject: [PATCH 3/3] Add Finish button redirecting to customer list after removal The customer detail page the popup was opened from no longer exists once the account is deleted. The done screen now shows a "Finish" button that dispatches an AppBridge redirect to /customers so the operator lands on a valid page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../anonymize/gdpr-removal/gdpr-removal-view.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/anonymizer/src/modules/anonymize/gdpr-removal/gdpr-removal-view.tsx b/apps/anonymizer/src/modules/anonymize/gdpr-removal/gdpr-removal-view.tsx index 85edb33e8..1d083bd97 100644 --- a/apps/anonymizer/src/modules/anonymize/gdpr-removal/gdpr-removal-view.tsx +++ b/apps/anonymizer/src/modules/anonymize/gdpr-removal/gdpr-removal-view.tsx @@ -1,3 +1,4 @@ +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; import { Box, Button, Text } from "@saleor/macaw-ui"; import { useState } from "react"; import { CombinedError, type OperationResult, useClient } from "urql"; @@ -167,6 +168,7 @@ const scanGiftCardsByEmail = async ( */ export const GdprRemovalView = ({ customerId }: { customerId: string }) => { const client = useClient(); + const { appBridge } = useAppBridge(); const { supported: checkoutDeletionSupported, fetching: versionFetching } = useCheckoutDeletionSupport(); @@ -364,6 +366,14 @@ export const GdprRemovalView = ({ customerId }: { customerId: string }) => { } }; + /* + * The customer detail page this popup was opened from no longer exists once + * the account is deleted, so navigate the Dashboard back to the customer list. + */ + const handleFinish = () => { + appBridge?.dispatch(actions.Redirect({ to: "/customers" })); + }; + const giftCardBalances = scanned ? aggregateBalancesByCurrency(scanned.giftCards) : []; return ( @@ -485,7 +495,8 @@ export const GdprRemovalView = ({ customerId }: { customerId: string }) => { ))} )} - + +