Add data request pages to compliance portal

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é <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-17 17:57:35 +02:00
parent e7ebab0d58
commit 6623cbc6f2
36 changed files with 1820 additions and 70 deletions

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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

View File

@@ -0,0 +1,185 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<RequestsPageQuery>;
}
// 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>(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 = (
<Button
variant="soft"
color="neutral"
highContrast
iconStart={<PlusIcon />}
onClick={onNewRequest}
>
{t("newRequest")}
</Button>
);
return (
<>
<PageHeader title={t("title")} count={requests.length} actions={newRequestButton} />
<div className={page()}>
<div className={results()}>
<ListErrorBoundary
onRetry={done => refetch({}, { fetchPolicy: "network-only", onComplete: done })}
>
{requests.length === 0
? (
<EmptyState
icon={<NoteIcon />}
title={t("empty.title")}
description={t("empty.description")}
action={newRequestButton}
/>
)
: (
<>
<div className={card()}>
{requests.map(request => (
<RightsRequestListItem key={request.id} rightsRequestKey={request} />
))}
</div>
{hasNext && (
<div className={loadMore()}>
<Button
variant="soft"
color="neutral"
onClick={() => loadNext(50)}
loading={isLoadingNext}
>
{t("loadMore")}
</Button>
</div>
)}
</>
)}
</ListErrorBoundary>
</div>
</div>
{viewer != null && (
<NewRequestDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
connectionId={connectionId}
viewerEmail={viewer.email}
viewerName={viewer.fullName}
/>
)}
</>
);
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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>(requestsPageQuery);
useEffect(() => {
loadQuery({});
}, [loadQuery]);
if (!queryRef) {
return <RequestsPageSkeleton />;
}
return <RequestsPage queryRef={queryRef} />;
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<>
<HeaderBand>
<div className="flex w-full items-center justify-between gap-4">
<HeadingSkeleton size={7} className="w-64" />
<div className="h-8 w-32 animate-pulse rounded-2 bg-sand-3" />
</div>
</HeaderBand>
<div className={page()}>
<div className={results()}>
<div className={card()}>
{ROW_PLACEHOLDERS.map(row => (
<div
key={row}
className="h-16 animate-pulse border-b border-sand-a3 bg-sand-2 last:border-b-0"
/>
))}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,204 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPopup>
<NewRequestForm
onClose={() => onOpenChange(false)}
connectionId={connectionId}
viewerEmail={viewerEmail}
viewerName={viewerName}
/>
</DialogPopup>
</Dialog>
);
}
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<SubmittableRightsRequestType>("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 (
<div className={success()}>
<span className={successIcon()}>
<CheckIcon weight="bold" />
</span>
<div className="flex flex-col gap-1">
<Text size={3} weight="medium" color="neutral" highContrast>
{t("dialog.success.title")}
</Text>
<Text size={2} color="faint">
{t("dialog.success.description")}
</Text>
</div>
<Button variant="soft" color="neutral" onClick={onClose}>
{t("dialog.success.close")}
</Button>
</div>
);
}
return (
<form onSubmit={(e) => { void onSubmit(e); }}>
<DialogHeader>
<DialogTitle>{t("dialog.title")}</DialogTitle>
<DialogDescription>{t("dialog.description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className={root()}>
<div className={label()}>
<Text size={2} weight="medium" color="neutral" highContrast>
{t("dialog.typeLabel")}
</Text>
<SegmentedControl
value={type}
onValueChange={value => setType(value as SubmittableRightsRequestType)}
>
{submittableRightsRequestTypes.map(option => (
<SegmentedControlItem key={option} value={option}>
{t(`typeOption.${option}`)}
</SegmentedControlItem>
))}
</SegmentedControl>
</div>
{config.showDeletionWarning && (
<Callout color="amber" variant="soft" icon={<WarningIcon weight="fill" />}>
{t("form.deletionWarning")}
</Callout>
)}
<Field label={config.nameOptional ? t("form.nameOptional") : t("form.name")}>
<TextField
value={name}
placeholder={t("form.namePlaceholder")}
onChange={e => setName(e.target.value)}
/>
</Field>
<Field label={t("form.email")}>
<TextField value={viewerEmail} readOnly disabled />
</Field>
<Field label={t(`form.details.${type}.label`)}>
<Textarea
value={details}
placeholder={t(`form.details.${type}.placeholder`)}
onChange={e => setDetails(e.target.value)}
/>
</Field>
</div>
</DialogBody>
<DialogFooter>
<Button type="button" variant="soft" color="neutral" onClick={onClose}>
{t("dialog.cancel")}
</Button>
<Button type="submit" variant="solid" color="neutral" highContrast loading={isSubmitting}>
{t("dialog.submit")}
</Button>
</DialogFooter>
</form>
);
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 { Badge } from "@probo/ui/src/v2/Badge/Badge";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { formatRelativeTime } from "#/lib/datetime/relativeTime";
import {
formatRightsRequestReference,
getRightsRequestStatusBadge,
getRightsRequestTypeIcon,
} from "../_lib/rightsRequest";
import type { RightsRequestListItem_rightsRequest$key } from "./__generated__/RightsRequestListItem_rightsRequest.graphql";
import { rightsRequestList } from "./variants";
const rightsRequestListItemFragment = graphql`
fragment RightsRequestListItem_rightsRequest on RightsRequest {
id
requestType
requestState
actionTaken
createdAt
}
`;
interface RightsRequestListItemProps {
rightsRequestKey: RightsRequestListItem_rightsRequest$key;
}
// A single data request row: type icon + title, reference and optional response
// message, with the submitted time and a status badge on the trailing edge.
export function RightsRequestListItem({ rightsRequestKey }: RightsRequestListItemProps) {
const { t, i18n } = useTranslation("requests");
const request = useFragment(rightsRequestListItemFragment, rightsRequestKey);
const badge = getRightsRequestStatusBadge(request.requestState);
const reference = formatRightsRequestReference(request.id, request.createdAt);
const { item, icon, content, subline, trailing } = rightsRequestList();
return (
<div className={item()}>
<span className={icon()}>
{getRightsRequestTypeIcon(request.requestType)}
</span>
<div className={content()}>
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{t(`types.${request.requestType}`)}
</Text>
<div className={subline()}>
<Text size={1} color="gold">
{reference}
</Text>
{request.actionTaken != null && request.actionTaken !== "" && (
<Text size={1} color="faint" className="truncate">
{`· ${request.actionTaken}`}
</Text>
)}
</div>
</div>
<div className={trailing()}>
<Text size={1} color="faint">
{formatRelativeTime(request.createdAt, i18n.language)}
</Text>
<Badge color={badge.color} variant={badge.variant}>
{t(`status.${request.requestState}`)}
</Badge>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 { tv } from "tailwind-variants/lite";
// Data request list: a bordered card of rows, each with a leading type icon, a
// title + reference/message subline, and a trailing time + status badge.
export const rightsRequestList = tv({
slots: {
card: "overflow-hidden rounded-4 border border-sand-a4 bg-sand-1",
item: "flex items-center gap-4 border-b border-sand-a3 px-4 py-3 last:border-b-0",
icon: "flex size-9 shrink-0 items-center justify-center rounded-3 bg-sand-3 text-sand-a11 [&_svg]:size-4",
content: "flex min-w-0 flex-1 flex-col gap-0.5",
subline: "flex min-w-0 items-center gap-1.5",
trailing: "flex shrink-0 items-center gap-3",
},
});
// Segmented type selector wrapping under the dialog header, plus the vertical
// stack of form fields.
export const newRequestForm = tv({
slots: {
root: "flex flex-col gap-4",
label: "flex flex-col gap-1.5",
success: "flex flex-col items-center gap-3 px-2 py-6 text-center",
successIcon: "flex size-10 items-center justify-center rounded-full bg-green-3 text-green-11 [&_svg]:size-6",
},
});

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 {
ExportIcon,
FileArrowDownIcon,
NoteIcon,
PencilSimpleIcon,
ProhibitIcon,
TrashIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
import type { RightsRequestState, RightsRequestType } from "@probo/helpers";
import { createElement, type ReactNode } from "react";
type BadgeColor = "neutral" | "gold" | "red" | "green" | "amber" | "sky";
type BadgeVariant = "solid" | "soft" | "surface" | "outline";
// Types the portal lets a data subject submit. PORTABILITY is intentionally
// excluded pending a dedicated export/transfer flow, but still renders in the
// list (via the metadata below) when created from the console.
export const submittableRightsRequestTypes = [
"ACCESS",
"DELETION",
"RECTIFICATION",
"OBJECTION",
"COMPLAINT",
] as const satisfies readonly RightsRequestType[];
export type SubmittableRightsRequestType = (typeof submittableRightsRequestTypes)[number];
// Icon element per type, covering all six so console-created rows (e.g.
// PORTABILITY) still render a glyph in the list. Elements are created once at
// module scope so consumers render them without creating components per render.
const rightsRequestTypeIcons: Record<RightsRequestType, ReactNode> = {
ACCESS: createElement(FileArrowDownIcon),
DELETION: createElement(TrashIcon),
RECTIFICATION: createElement(PencilSimpleIcon),
PORTABILITY: createElement(ExportIcon),
OBJECTION: createElement(ProhibitIcon),
COMPLAINT: createElement(WarningCircleIcon),
};
export function getRightsRequestTypeIcon(type: RightsRequestType): ReactNode {
return rightsRequestTypeIcons[type] ?? createElement(NoteIcon);
}
// Badge treatment for the portal's status vocabulary (Pending / Processing /
// Completed / Rejected), mapped from the model's states.
export function getRightsRequestStatusBadge(
state: RightsRequestState,
): { color: BadgeColor; variant: BadgeVariant } {
switch (state) {
case "TODO":
return { color: "amber", variant: "soft" };
case "IN_PROGRESS":
return { color: "neutral", variant: "outline" };
case "DONE":
return { color: "green", variant: "soft" };
case "REJECTED":
return { color: "red", variant: "soft" };
default:
return { color: "neutral", variant: "soft" };
}
}
// Per-type dialog form configuration. The details field's label/placeholder are
// type-specific; the deletion warning callout only shows for DELETION, and the
// name is optional for an abuse report (COMPLAINT).
export const rightsRequestFormConfig: Record<
SubmittableRightsRequestType,
{ showDeletionWarning: boolean; nameOptional: boolean }
> = {
ACCESS: { showDeletionWarning: false, nameOptional: false },
DELETION: { showDeletionWarning: true, nameOptional: false },
RECTIFICATION: { showDeletionWarning: false, nameOptional: false },
OBJECTION: { showDeletionWarning: false, nameOptional: false },
COMPLAINT: { showDeletionWarning: false, nameOptional: true },
};
// Human-facing reference derived from the created year and a short suffix of the
// opaque id (display-only; not a stored sequential number).
export function formatRightsRequestReference(id: string, createdAt: string): string {
const year = new Date(createdAt).getFullYear();
const suffix = id.replace(/[^a-zA-Z0-9]/g, "").slice(-6).toUpperCase();
return `REQ-${year}-${suffix}`;
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 { graphql } from "relay-runtime";
import { useMutation } from "#/lib/relay/useMutation";
import type { useCreateRightsRequestMutation } from "./__generated__/useCreateRightsRequestMutation.graphql";
const createRightsRequestMutation = graphql`
mutation useCreateRightsRequestMutation(
$input: CreateRightsRequestInput!
$connections: [ID!]!
) {
createRightsRequest(input: $input) {
rightsRequestEdge @prependEdge(connections: $connections) {
node {
id
...RightsRequestListItem_rightsRequest
}
}
}
}
`;
// Submits a data subject request and prepends it into the viewer's list. The
// mutation requires a verified viewer; the server attributes it to their email.
export function useCreateRightsRequest() {
return useMutation<useCreateRightsRequestMutation>(createRightsRequestMutation);
}

View File

@@ -0,0 +1,71 @@
{
"title": "Data Requests",
"newRequest": "New Request",
"loadMore": "Load more",
"empty": {
"title": "No requests yet",
"description": "Requests you submit will appear here so you can track their progress."
},
"types": {
"ACCESS": "Data Access",
"DELETION": "Data Deletion",
"RECTIFICATION": "Data Rectification",
"PORTABILITY": "Data Portability",
"OBJECTION": "Processing Objection",
"COMPLAINT": "Abuse Report"
},
"status": {
"TODO": "Pending",
"IN_PROGRESS": "Processing",
"DONE": "Completed",
"REJECTED": "Rejected"
},
"typeOption": {
"ACCESS": "Access my data",
"DELETION": "Delete my data",
"RECTIFICATION": "Correct my data",
"OBJECTION": "Object to processing",
"COMPLAINT": "Report abuse"
},
"dialog": {
"title": "New Request",
"description": "Choose a request type and fill in the details below.",
"typeLabel": "Request type",
"cancel": "Cancel",
"submit": "Submit Request",
"success": {
"title": "Request submitted",
"description": "We'll process your request and respond within 30 days.",
"close": "Close"
}
},
"form": {
"name": "Full Name",
"nameOptional": "Your Name (optional)",
"namePlaceholder": "John Doe",
"email": "Email Address",
"deletionWarning": "Data deletion is permanent and cannot be undone. Some data may be retained for legal obligations.",
"details": {
"ACCESS": {
"label": "Details of your request",
"placeholder": "I'd like a copy of all personal data associated with my account…"
},
"DELETION": {
"label": "Scope of deletion request",
"placeholder": "Please delete all personal data including…"
},
"RECTIFICATION": {
"label": "What should be corrected?",
"placeholder": "The following information is inaccurate…"
},
"OBJECTION": {
"label": "What are you objecting to?",
"placeholder": "I object to the processing of my data for…"
},
"COMPLAINT": {
"label": "Description of the concern",
"placeholder": "I noticed that…"
}
}
}
}

View File

@@ -0,0 +1,71 @@
{
"title": "Demandes de données",
"newRequest": "Nouvelle demande",
"loadMore": "Charger plus",
"empty": {
"title": "Aucune demande pour le moment",
"description": "Les demandes que vous soumettez apparaîtront ici afin que vous puissiez suivre leur avancement."
},
"types": {
"ACCESS": "Accès aux données",
"DELETION": "Suppression des données",
"RECTIFICATION": "Rectification des données",
"PORTABILITY": "Portabilité des données",
"OBJECTION": "Opposition au traitement",
"COMPLAINT": "Signalement d'abus"
},
"status": {
"TODO": "En attente",
"IN_PROGRESS": "En cours",
"DONE": "Terminée",
"REJECTED": "Rejetée"
},
"typeOption": {
"ACCESS": "Accéder à mes données",
"DELETION": "Supprimer mes données",
"RECTIFICATION": "Corriger mes données",
"OBJECTION": "M'opposer au traitement",
"COMPLAINT": "Signaler un abus"
},
"dialog": {
"title": "Nouvelle demande",
"description": "Choisissez un type de demande et remplissez les détails ci-dessous.",
"typeLabel": "Type de demande",
"cancel": "Annuler",
"submit": "Envoyer la demande",
"success": {
"title": "Demande envoyée",
"description": "Nous traiterons votre demande et vous répondrons sous 30 jours.",
"close": "Fermer"
}
},
"form": {
"name": "Nom complet",
"nameOptional": "Votre nom (facultatif)",
"namePlaceholder": "Jean Dupont",
"email": "Adresse e-mail",
"deletionWarning": "La suppression des données est définitive et irréversible. Certaines données peuvent être conservées pour des obligations légales.",
"details": {
"ACCESS": {
"label": "Détails de votre demande",
"placeholder": "Je souhaite une copie de toutes les données personnelles associées à mon compte…"
},
"DELETION": {
"label": "Portée de la demande de suppression",
"placeholder": "Veuillez supprimer toutes les données personnelles, notamment…"
},
"RECTIFICATION": {
"label": "Que faut-il corriger ?",
"placeholder": "Les informations suivantes sont inexactes…"
},
"OBJECTION": {
"label": "À quoi vous opposez-vous ?",
"placeholder": "Je m'oppose au traitement de mes données pour…"
},
"COMPLAINT": {
"label": "Description du problème",
"placeholder": "J'ai remarqué que…"
}
}
}
}

View File

@@ -18,22 +18,15 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { PlusIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { useTranslation } from "react-i18next";
import { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";
import { PageHeader } from "#/components/PageHeader/PageHeader";
import { RequestsPageSkeleton } from "./RequestsPageSkeleton";
export default function RequestsPage() {
const { t } = useTranslation();
return (
<PageHeader
title={t("requests.title")}
actions={(
<Button variant="soft" color="neutral" highContrast iconStart={<PlusIcon />}>
{t("requests.newRequest")}
</Button>
)}
/>
);
}
export const requestRoutes = [
{
path: "requests",
Fallback: RequestsPageSkeleton,
Component: lazy(() => import("./RequestsPageLoader")),
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 { tv } from "tailwind-variants/lite";
// Data requests page shell: a centered content column below the header band.
export const requestsLayout = tv({
slots: {
page: "flex w-full flex-col items-center px-8 py-8",
results: "flex w-full max-w-5xl flex-col gap-6",
loadMore: "flex justify-center",
},
});

View File

@@ -30,6 +30,7 @@ import { documentRoutes } from "#/pages/documents/routes";
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
import { ndaRoutes } from "#/pages/nda/routes";
import { requestRoutes } from "#/pages/requests/routes";
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
import { updateRoutes } from "#/pages/updates/routes";
@@ -54,10 +55,7 @@ const routes = [
...documentRoutes,
...subprocessorRoutes,
...updateRoutes,
{
path: "requests",
Component: lazy(() => import("#/pages/RequestsPage")),
},
...requestRoutes,
{
path: "*",
Component: lazy(() => import("#/pages/NotFoundPage")),