From 6623cbc6f210c6c9f684a8f3fe68b59e21f5c978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 17 Jul 2026 17:57:35 +0200 Subject: [PATCH] Add data request pages to compliance portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let trust-portal data subjects submit and track GDPR/CCPA rights requests. The new Data Requests page lists the viewer's own requests and a dialog submits new ones, scoped server-side to the verified viewer email so former or inactive users can still exercise their rights. Submission requires magic-link sign-in (reusing the existing gate) but not the NDA gate. Extend the shared rights_request enums with RECTIFICATION, OBJECTION and COMPLAINT types plus a REJECTED state, and keep the console GraphQL, @probo/helpers and the MCP specification in sync. Expose a trust GraphQL surface (myRightsRequests query, createRightsRequest mutation) backed by a trust service and contact-scoped coredata loaders. Add the missing v2 UI kit primitives the dialog needs on top of Base UI: a SegmentedControl radio-cards group, a form Textarea, and a Field wrapper. Signed-off-by: Émile Ré --- .../compliance-portal/src/_locales/en-US.json | 4 - .../compliance-portal/src/_locales/fr-FR.json | 4 - .../src/lib/auth/continueUrl.ts | 11 + .../src/pages/requests/RequestsPage.tsx | 185 ++++++++++++++++ .../src/pages/requests/RequestsPageLoader.tsx | 40 ++++ .../pages/requests/RequestsPageSkeleton.tsx | 56 +++++ .../requests/_components/NewRequestDialog.tsx | 204 ++++++++++++++++++ .../_components/RightsRequestListItem.tsx | 91 ++++++++ .../pages/requests/_components/variants.ts | 45 ++++ .../src/pages/requests/_lib/rightsRequest.ts | 104 +++++++++ .../requests/_lib/useCreateRightsRequest.ts | 47 ++++ .../src/pages/requests/_locales/en-US.json | 71 ++++++ .../src/pages/requests/_locales/fr-FR.json | 71 ++++++ .../{RequestsPage.tsx => requests/routes.ts} | 27 +-- .../src/pages/requests/variants.ts | 30 +++ apps/compliance-portal/src/routes.tsx | 6 +- .../RightsRequestDetailsPage.tsx | 4 +- .../dialogs/CreateRightsRequestDialog.tsx | 4 +- packages/helpers/src/rightsRequest.ts | 66 +++--- .../v2/SegmentedControl/SegmentedControl.tsx | 61 ++++++ .../SegmentedControl/SegmentedControlItem.tsx | 40 ++++ .../ui/src/v2/SegmentedControl/variants.ts | 41 ++++ packages/ui/src/v2/form/Field.tsx | 49 +++++ packages/ui/src/v2/form/Textarea.tsx | 45 ++++ packages/ui/src/v2/form/variants.ts | 36 ++++ pkg/coredata/migrations/20260717T133746Z.sql | 25 +++ pkg/coredata/rights_request_state.go | 5 +- pkg/coredata/rights_request_type.go | 17 +- pkg/coredata/rights_requests.go | 92 ++++++++ .../console/v1/graphql/rights_request.graphql | 16 ++ pkg/server/api/mcp/v1/specification.yaml | 4 + .../trust/v1/graphql/rights_request.graphql | 83 +++++++ .../api/trust/v1/rights_request_resolvers.go | 85 ++++++++ .../api/trust/v1/types/rights_request.go | 66 ++++++ pkg/trust/rights_request_service.go | 153 +++++++++++++ pkg/trust/service.go | 2 + 36 files changed, 1820 insertions(+), 70 deletions(-) create mode 100644 apps/compliance-portal/src/pages/requests/RequestsPage.tsx create mode 100644 apps/compliance-portal/src/pages/requests/RequestsPageLoader.tsx create mode 100644 apps/compliance-portal/src/pages/requests/RequestsPageSkeleton.tsx create mode 100644 apps/compliance-portal/src/pages/requests/_components/NewRequestDialog.tsx create mode 100644 apps/compliance-portal/src/pages/requests/_components/RightsRequestListItem.tsx create mode 100644 apps/compliance-portal/src/pages/requests/_components/variants.ts create mode 100644 apps/compliance-portal/src/pages/requests/_lib/rightsRequest.ts create mode 100644 apps/compliance-portal/src/pages/requests/_lib/useCreateRightsRequest.ts create mode 100644 apps/compliance-portal/src/pages/requests/_locales/en-US.json create mode 100644 apps/compliance-portal/src/pages/requests/_locales/fr-FR.json rename apps/compliance-portal/src/pages/{RequestsPage.tsx => requests/routes.ts} (67%) create mode 100644 apps/compliance-portal/src/pages/requests/variants.ts create mode 100644 packages/ui/src/v2/SegmentedControl/SegmentedControl.tsx create mode 100644 packages/ui/src/v2/SegmentedControl/SegmentedControlItem.tsx create mode 100644 packages/ui/src/v2/SegmentedControl/variants.ts create mode 100644 packages/ui/src/v2/form/Field.tsx create mode 100644 packages/ui/src/v2/form/Textarea.tsx create mode 100644 pkg/coredata/migrations/20260717T133746Z.sql create mode 100644 pkg/server/api/trust/v1/graphql/rights_request.graphql create mode 100644 pkg/server/api/trust/v1/rights_request_resolvers.go create mode 100644 pkg/server/api/trust/v1/types/rights_request.go create mode 100644 pkg/trust/rights_request_service.go diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index eae17b626..08b1a86aa 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -81,10 +81,6 @@ "viewAll": "View all" } }, - "requests": { - "title": "Data Requests", - "newRequest": "New Request" - }, "notFound": { "title": "Page not found", "description": "The page you are looking for does not exist or has moved.", diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index e6b17aecc..006541894 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -81,10 +81,6 @@ "viewAll": "Voir tout" } }, - "requests": { - "title": "Demandes de données", - "newRequest": "Nouvelle demande" - }, "notFound": { "title": "Page introuvable", "description": "La page que vous recherchez n'existe pas ou a été déplacée.", diff --git a/apps/compliance-portal/src/lib/auth/continueUrl.ts b/apps/compliance-portal/src/lib/auth/continueUrl.ts index a5e0aa792..9390fc2e8 100644 --- a/apps/compliance-portal/src/lib/auth/continueUrl.ts +++ b/apps/compliance-portal/src/lib/auth/continueUrl.ts @@ -30,6 +30,9 @@ export const REQUEST_ALL_PARAM = "request-all"; export const REQUEST_DOCUMENT_PARAM = "request-document-id"; export const REQUEST_REPORT_PARAM = "request-report-id"; export const REQUEST_FILE_PARAM = "request-file-id"; +// Marker that re-opens the "New Request" dialog once the user lands back +// authenticated (the data request form gates on sign-in before it opens). +export const NEW_REQUEST_PARAM = "new-request"; // Validates a `continue` target before we navigate to it. Only same-origin URLs // under the portal's path prefix are accepted; anything else falls back to the @@ -73,6 +76,14 @@ export function buildRequestAccessContinueUrl(param: string, id: string): string return url.toString(); } +// Absolute URL of the current page with the new-request marker set, so the data +// request dialog re-opens after sign-in. +export function buildNewRequestContinueUrl(): string { + const url = new URL(window.location.href); + url.searchParams.set(NEW_REQUEST_PARAM, "true"); + return url.toString(); +} + // Maps a caught auth-gate error to the route that resolves it, carrying the // given `continueUrl` so the user returns here (and any deferred request // resumes) once the gate is cleared. Returns null for non-gate errors. Shared diff --git a/apps/compliance-portal/src/pages/requests/RequestsPage.tsx b/apps/compliance-portal/src/pages/requests/RequestsPage.tsx new file mode 100644 index 000000000..2bed3fcbb --- /dev/null +++ b/apps/compliance-portal/src/pages/requests/RequestsPage.tsx @@ -0,0 +1,185 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { NoteIcon, PlusIcon } from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { PreloadedQuery } from "react-relay"; +import { graphql, usePaginationFragment, usePreloadedQuery } from "react-relay"; +import { useSearchParams } from "react-router"; + +import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { ListErrorBoundary } from "#/components/errors/ListErrorBoundary"; +import { PageHeader } from "#/components/PageHeader/PageHeader"; +import { buildNewRequestContinueUrl, NEW_REQUEST_PARAM } from "#/lib/auth/continueUrl"; +import { useSignInDialog } from "#/lib/auth/signInDialogContext"; + +import type { RequestsPage_query$key } from "./__generated__/RequestsPage_query.graphql"; +import type { RequestsPageQuery } from "./__generated__/RequestsPageQuery.graphql"; +import type { RequestsPageRefetchQuery } from "./__generated__/RequestsPageRefetchQuery.graphql"; +import { NewRequestDialog } from "./_components/NewRequestDialog"; +import { RightsRequestListItem } from "./_components/RightsRequestListItem"; +import { rightsRequestList } from "./_components/variants"; +import { requestsLayout } from "./variants"; + +export const requestsPageQuery = graphql` + query RequestsPageQuery { + viewer { + email + fullName + } + ...RequestsPage_query + } +`; + +const requestsPageFragment = graphql` + fragment RequestsPage_query on Query + @refetchable(queryName: "RequestsPageRefetchQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + after: { type: "CursorKey" } + ) { + myRightsRequests(first: $first, after: $after) + @connection(key: "RequestsPage_myRightsRequests") { + __id + edges { + node { + id + ...RightsRequestListItem_rightsRequest + } + } + } + } +`; + +interface RequestsPageProps { + queryRef: PreloadedQuery; +} + +// Data Requests page: the viewer's own data subject requests, with a "New +// Request" flow gated behind sign-in for guests. Submission and the personal +// list are scoped to the verified viewer's email server-side. +export function RequestsPage({ queryRef }: RequestsPageProps) { + const { t } = useTranslation("requests"); + const root = usePreloadedQuery(requestsPageQuery, queryRef); + const { data, loadNext, hasNext, isLoadingNext, refetch } = usePaginationFragment< + RequestsPageRefetchQuery, + RequestsPage_query$key + >(requestsPageFragment, root); + + const { openSignIn } = useSignInDialog(); + const [searchParams, setSearchParams] = useSearchParams(); + const [dialogOpen, setDialogOpen] = useState(false); + + const viewer = root.viewer; + const requests = data.myRightsRequests?.edges?.map(edge => edge.node) ?? []; + const connectionId = data.myRightsRequests?.__id ?? ""; + + // After a guest signs in to submit, they land back here with the new-request + // marker; open the dialog once and drop the marker so a reload can't re-open. + const resumed = useRef(false); + useEffect(() => { + if (resumed.current || viewer == null || searchParams.get(NEW_REQUEST_PARAM) == null) { + return; + } + resumed.current = true; + setDialogOpen(true); + const next = new URLSearchParams(searchParams); + next.delete(NEW_REQUEST_PARAM); + setSearchParams(next, { replace: true }); + }, [viewer, searchParams, setSearchParams]); + + const onNewRequest = () => { + if (viewer != null) { + setDialogOpen(true); + return; + } + openSignIn({ continueTo: buildNewRequestContinueUrl() }); + }; + + const { page, results, loadMore } = requestsLayout(); + const { card } = rightsRequestList(); + + const newRequestButton = ( + + ); + + return ( + <> + +
+
+ refetch({}, { fetchPolicy: "network-only", onComplete: done })} + > + {requests.length === 0 + ? ( + } + title={t("empty.title")} + description={t("empty.description")} + action={newRequestButton} + /> + ) + : ( + <> +
+ {requests.map(request => ( + + ))} +
+ {hasNext && ( +
+ +
+ )} + + )} +
+
+
+ {viewer != null && ( + + )} + + ); +} diff --git a/apps/compliance-portal/src/pages/requests/RequestsPageLoader.tsx b/apps/compliance-portal/src/pages/requests/RequestsPageLoader.tsx new file mode 100644 index 000000000..0f1bc7f53 --- /dev/null +++ b/apps/compliance-portal/src/pages/requests/RequestsPageLoader.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { RequestsPageQuery } from "./__generated__/RequestsPageQuery.graphql"; +import { RequestsPage, requestsPageQuery } from "./RequestsPage"; +import { RequestsPageSkeleton } from "./RequestsPageSkeleton"; + +export default function RequestsPageLoader() { + const [queryRef, loadQuery] = useQueryLoader(requestsPageQuery); + + useEffect(() => { + loadQuery({}); + }, [loadQuery]); + + if (!queryRef) { + return ; + } + + return ; +} diff --git a/apps/compliance-portal/src/pages/requests/RequestsPageSkeleton.tsx b/apps/compliance-portal/src/pages/requests/RequestsPageSkeleton.tsx new file mode 100644 index 000000000..1633e4908 --- /dev/null +++ b/apps/compliance-portal/src/pages/requests/RequestsPageSkeleton.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton"; + +import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; + +import { rightsRequestList } from "./_components/variants"; +import { requestsLayout } from "./variants"; + +const ROW_PLACEHOLDERS = ["a", "b", "c", "d"]; + +export function RequestsPageSkeleton() { + const { page, results } = requestsLayout(); + const { card } = rightsRequestList(); + + return ( + <> + +
+ +
+
+ +
+
+
+ {ROW_PLACEHOLDERS.map(row => ( +
+ ))} +
+
+
+ + ); +} diff --git a/apps/compliance-portal/src/pages/requests/_components/NewRequestDialog.tsx b/apps/compliance-portal/src/pages/requests/_components/NewRequestDialog.tsx new file mode 100644 index 000000000..8673f96f9 --- /dev/null +++ b/apps/compliance-portal/src/pages/requests/_components/NewRequestDialog.tsx @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { CheckIcon, WarningIcon } from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { Callout } from "@probo/ui/src/v2/Callout/Callout"; +import { Dialog } from "@probo/ui/src/v2/Dialog/Dialog"; +import { DialogBody } from "@probo/ui/src/v2/Dialog/DialogBody"; +import { DialogDescription } from "@probo/ui/src/v2/Dialog/DialogDescription"; +import { DialogFooter } from "@probo/ui/src/v2/Dialog/DialogFooter"; +import { DialogHeader } from "@probo/ui/src/v2/Dialog/DialogHeader"; +import { DialogPopup } from "@probo/ui/src/v2/Dialog/DialogPopup"; +import { DialogTitle } from "@probo/ui/src/v2/Dialog/DialogTitle"; +import { Field } from "@probo/ui/src/v2/form/Field"; +import { Textarea } from "@probo/ui/src/v2/form/Textarea"; +import { TextField } from "@probo/ui/src/v2/form/TextField"; +import { SegmentedControl } from "@probo/ui/src/v2/SegmentedControl/SegmentedControl"; +import { SegmentedControlItem } from "@probo/ui/src/v2/SegmentedControl/SegmentedControlItem"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + rightsRequestFormConfig, + type SubmittableRightsRequestType, + submittableRightsRequestTypes, +} from "../_lib/rightsRequest"; +import { useCreateRightsRequest } from "../_lib/useCreateRightsRequest"; + +import { newRequestForm } from "./variants"; + +interface NewRequestDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + // Relay connection id to prepend the created request into. + connectionId: string; + // Verified viewer identity, used to prefill the (read-only) email and name. + viewerEmail: string; + viewerName: string; +} + +// The "New Request" modal. The form lives in a child that only mounts while the +// dialog is open, so each open starts from a clean slate without a reset effect. +export function NewRequestDialog({ + open, + onOpenChange, + connectionId, + viewerEmail, + viewerName, +}: NewRequestDialogProps) { + return ( + + + onOpenChange(false)} + connectionId={connectionId} + viewerEmail={viewerEmail} + viewerName={viewerName} + /> + + + ); +} + +interface NewRequestFormProps { + onClose: () => void; + connectionId: string; + viewerEmail: string; + viewerName: string; +} + +function NewRequestForm({ onClose, connectionId, viewerEmail, viewerName }: NewRequestFormProps) { + const { t } = useTranslation("requests"); + const [submit, isSubmitting] = useCreateRightsRequest(); + + const [type, setType] = useState("ACCESS"); + const [name, setName] = useState(viewerName); + const [details, setDetails] = useState(""); + const [submitted, setSubmitted] = useState(false); + + const config = rightsRequestFormConfig[type]; + const { root, label, success, successIcon } = newRequestForm(); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + try { + await submit({ + variables: { + input: { + requestType: type, + dataSubject: name.trim() === "" ? null : name.trim(), + details: details.trim() === "" ? null : details.trim(), + }, + connections: [connectionId], + }, + }); + setSubmitted(true); + } catch { + // Errors are surfaced by the mutation notifier; keep the form open. + } + }; + + if (submitted) { + return ( +
+ + + +
+ + {t("dialog.success.title")} + + + {t("dialog.success.description")} + +
+ +
+ ); + } + + return ( +
{ void onSubmit(e); }}> + + {t("dialog.title")} + {t("dialog.description")} + + + +
+
+ + {t("dialog.typeLabel")} + + setType(value as SubmittableRightsRequestType)} + > + {submittableRightsRequestTypes.map(option => ( + + {t(`typeOption.${option}`)} + + ))} + +
+ + {config.showDeletionWarning && ( + }> + {t("form.deletionWarning")} + + )} + + + setName(e.target.value)} + /> + + + + + + + +