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")),

View File

@@ -65,8 +65,8 @@ import {
} from "../../../hooks/graph/RightsRequestGraph";
const updateRequestSchema = z.object({
requestType: z.enum(["ACCESS", "DELETION", "PORTABILITY"]),
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE"]),
requestType: z.enum(["ACCESS", "DELETION", "RECTIFICATION", "PORTABILITY", "OBJECTION", "COMPLAINT"]),
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE", "REJECTED"]),
dataSubject: z.string().optional(),
contact: z.string().optional(),
details: z.string().optional(),

View File

@@ -50,8 +50,8 @@ import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useCreateRightsRequest } from "../../../../hooks/graph/RightsRequestGraph";
const schema = z.object({
requestType: z.enum(["ACCESS", "DELETION", "PORTABILITY"]),
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE"]),
requestType: z.enum(["ACCESS", "DELETION", "RECTIFICATION", "PORTABILITY", "OBJECTION", "COMPLAINT"]),
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE", "REJECTED"]),
dataSubject: z.string().optional(),
contact: z.string().optional(),
details: z.string().optional(),

View File

@@ -20,43 +20,49 @@
type Translator = (s: string) => string;
export type RightsRequestType = "ACCESS" | "DELETION" | "PORTABILITY";
export type RightsRequestType =
| "ACCESS"
| "DELETION"
| "RECTIFICATION"
| "PORTABILITY"
| "OBJECTION"
| "COMPLAINT";
export const rightsRequestTypes = [
"ACCESS",
"DELETION",
"RECTIFICATION",
"PORTABILITY",
"OBJECTION",
"COMPLAINT",
] as const;
export type RightsRequestState = "TODO" | "IN_PROGRESS" | "DONE";
export type RightsRequestState = "TODO" | "IN_PROGRESS" | "DONE" | "REJECTED";
export const rightsRequestStates = [
"TODO",
"IN_PROGRESS",
"DONE",
"REJECTED",
] as const;
const rightsRequestTypeLabels: Record<RightsRequestType, string> = {
"ACCESS": "Access",
"DELETION": "Deletion",
"RECTIFICATION": "Rectification",
"PORTABILITY": "Portability",
"OBJECTION": "Objection",
"COMPLAINT": "Complaint",
};
export function getRightsRequestTypeLabel(__: Translator, type: RightsRequestType) {
switch (type) {
case "ACCESS":
return __("Access");
case "DELETION":
return __("Deletion");
case "PORTABILITY":
return __("Portability");
default:
return type;
}
return __(rightsRequestTypeLabels[type] ?? type);
}
export function getRightsRequestTypeOptions(__: Translator) {
return rightsRequestTypes.map((type) => ({
value: type,
label: __({
"ACCESS": "Access",
"DELETION": "Deletion",
"PORTABILITY": "Portability",
}[type]),
label: __(rightsRequestTypeLabels[type]),
}));
}
@@ -70,31 +76,27 @@ export const getRightsRequestStateVariant = (
return "info" as const;
case "DONE":
return "success" as const;
case "REJECTED":
return "danger" as const;
default:
return "neutral" as const;
}
};
const rightsRequestStateLabels: Record<RightsRequestState, string> = {
"TODO": "To Do",
"IN_PROGRESS": "In Progress",
"DONE": "Done",
"REJECTED": "Rejected",
};
export function getRightsRequestStateLabel(__: Translator, state: RightsRequestState) {
switch (state) {
case "TODO":
return __("To Do");
case "IN_PROGRESS":
return __("In Progress");
case "DONE":
return __("Done");
default:
return state;
}
return __(rightsRequestStateLabels[state] ?? state);
}
export function getRightsRequestStateOptions(__: Translator) {
return rightsRequestStates.map((state) => ({
value: state,
label: __({
"TODO": "To Do",
"IN_PROGRESS": "In Progress",
"DONE": "Done",
}[state]),
label: __(rightsRequestStateLabels[state]),
}));
}

View File

@@ -0,0 +1,61 @@
// 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 { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group";
import type { ReactNode } from "react";
import { segmentedControl } from "./variants";
export type SegmentedControlProps = {
// Single selected value (controlled).
value?: string;
// Single selected value (uncontrolled).
defaultValue?: string;
// Fired with the newly selected value. Never fired with an empty selection,
// so a value always stays selected (clicking the active item is a no-op).
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
children?: ReactNode;
};
// Single-select pill group. Wraps Base UI's array-based ToggleGroup with a
// friendlier single-value API.
export function SegmentedControl(props: SegmentedControlProps) {
const { value, defaultValue, onValueChange, disabled, className, children } = props;
const { root } = segmentedControl();
return (
<BaseToggleGroup
className={root({ className })}
disabled={disabled}
value={value != null ? [value] : undefined}
defaultValue={defaultValue != null ? [defaultValue] : undefined}
onValueChange={(groupValue) => {
const next = groupValue[0];
if (next != null) {
onValueChange?.(next);
}
}}
>
{children}
</BaseToggleGroup>
);
}

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 { Toggle as BaseToggle } from "@base-ui/react/toggle";
import type { ComponentProps } from "react";
import { segmentedControl } from "./variants";
export type SegmentedControlItemProps
= & Omit<ComponentProps<typeof BaseToggle>, "className">
& {
className?: string;
// Identifies this item within the group; matched against the group value.
value: string;
};
// A single segment. Pressed state is driven by Base UI (`data-pressed`).
export function SegmentedControlItem(props: SegmentedControlItemProps) {
const { className, ...rest } = props;
const { item } = segmentedControl();
return <BaseToggle className={item({ className })} {...rest} />;
}

View File

@@ -0,0 +1,41 @@
// 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";
// Segmented control (single-select radio-cards over Base UI's ToggleGroup).
// There is no surrounding track: the items are standalone bordered cards laid
// out on an equal-width grid that wraps to new rows. Using a grid (rather than
// flex-wrap) keeps every card the same width and prevents a lone wrapped item
// from stretching across its row. Each card keeps a 1px border at all sizes
// (the pressed state only darkens the border, so selection never shifts layout).
export const segmentedControl = tv({
slots: {
root: "grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-1",
item: [
"min-w-0 cursor-pointer select-none rounded-3 border border-sand-a6 bg-sand-1 px-4 py-3.5",
"text-center text-2 font-medium text-sand-12 outline-none transition-colors",
"hover:border-sand-a8",
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
"data-pressed:border-sand-a12",
"data-disabled:pointer-events-none data-disabled:opacity-50",
],
},
});

View File

@@ -0,0 +1,49 @@
// 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 type { ReactNode } from "react";
import { field } from "./variants";
export type FieldProps = {
// Text shown above the control. The control is nested inside the <label> so
// the association is implicit (no htmlFor / id threading required).
label?: ReactNode;
// Validation / server error shown below the control.
error?: ReactNode;
className?: string;
children: ReactNode;
};
// Vertical label + control + error grouping for form dialogs.
export function Field(props: FieldProps) {
const { label, error, className, children } = props;
const { root, label: labelSlot, labelText, error: errorSlot } = field();
return (
<div className={root({ className })}>
<label className={labelSlot()}>
{label != null && <span className={labelText()}>{label}</span>}
{children}
</label>
{error != null && <p className={errorSlot()}>{error}</p>}
</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 type { ComponentProps } from "react";
import { textArea } from "./variants";
export type TextareaProps
= & Omit<ComponentProps<"textarea">, "className">
& {
// Applied to the bordered container (the top-level element).
className?: string;
// Surface treatment (defaults to "surface").
variant?: "classic" | "surface" | "soft";
};
// Multi-line text input on a bordered surface mirroring TextField. The container
// is the top-level node; all native textarea props spread onto the inner control.
export function Textarea(props: TextareaProps) {
const { className, variant, rows = 4, ...textareaProps } = props;
const { root, textarea } = textArea({ variant });
return (
<div className={root({ className })}>
<textarea className={textarea()} rows={rows} {...textareaProps} />
</div>
);
}

View File

@@ -51,6 +51,42 @@ export const textField = tv({
},
});
// Multi-line text input, mirroring TextField's bordered surface. Base UI has no
// textarea primitive, so this styles a native <textarea>.
export const textArea = tv({
slots: {
root: [
"flex rounded-2 text-2 text-sand-12 transition-colors",
"focus-within:ring-2 focus-within:ring-sand-8 focus-within:ring-offset-1 focus-within:ring-offset-sand-1",
"has-[textarea:disabled]:pointer-events-none has-[textarea:disabled]:opacity-50",
],
textarea: [
"min-h-16 w-full resize-y bg-transparent px-2 py-1.5 text-sand-12 outline-none",
"placeholder:text-sand-a9",
],
},
variants: {
variant: {
classic: { root: "border border-sand-a5 bg-sand-1 inset-shadow-2" },
surface: { root: "border border-sand-a5 bg-sand-1" },
soft: { root: "bg-gold-3" },
},
},
defaultVariants: {
variant: "surface",
},
});
// Vertical label + control + error grouping used by form dialogs.
export const field = tv({
slots: {
root: "flex flex-col gap-1.5",
label: "flex flex-col gap-1.5",
labelText: "text-2 font-medium text-sand-12",
error: "text-1 text-red-a11",
},
});
export const textFieldSkeleton = tv({
base: "inline-block animate-pulse rounded-2 bg-sand-3 align-middle",
variants: {

View File

@@ -0,0 +1,25 @@
-- 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.
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'RECTIFICATION';
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'OBJECTION';
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'COMPLAINT';
ALTER TYPE rights_request_state ADD VALUE IF NOT EXISTS 'REJECTED';

View File

@@ -31,6 +31,7 @@ const (
RightsRequestStateTodo RightsRequestState = "TODO"
RightsRequestStateInProgress RightsRequestState = "IN_PROGRESS"
RightsRequestStateDone RightsRequestState = "DONE"
RightsRequestStateRejected RightsRequestState = "REJECTED"
)
var (
@@ -44,6 +45,7 @@ func RightsRequestStates() []RightsRequestState {
RightsRequestStateTodo,
RightsRequestStateInProgress,
RightsRequestStateDone,
RightsRequestStateRejected,
}
}
@@ -52,7 +54,8 @@ func (v RightsRequestState) IsValid() bool {
case
RightsRequestStateTodo,
RightsRequestStateInProgress,
RightsRequestStateDone:
RightsRequestStateDone,
RightsRequestStateRejected:
return true
}

View File

@@ -28,9 +28,12 @@ import (
type RightsRequestType string
const (
RightsRequestTypeAccess RightsRequestType = "ACCESS"
RightsRequestTypeDeletion RightsRequestType = "DELETION"
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
RightsRequestTypeAccess RightsRequestType = "ACCESS"
RightsRequestTypeDeletion RightsRequestType = "DELETION"
RightsRequestTypeRectification RightsRequestType = "RECTIFICATION"
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
RightsRequestTypeObjection RightsRequestType = "OBJECTION"
RightsRequestTypeComplaint RightsRequestType = "COMPLAINT"
)
var (
@@ -43,7 +46,10 @@ func RightsRequestTypes() []RightsRequestType {
return []RightsRequestType{
RightsRequestTypeAccess,
RightsRequestTypeDeletion,
RightsRequestTypeRectification,
RightsRequestTypePortability,
RightsRequestTypeObjection,
RightsRequestTypeComplaint,
}
}
@@ -52,7 +58,10 @@ func (v RightsRequestType) IsValid() bool {
case
RightsRequestTypeAccess,
RightsRequestTypeDeletion,
RightsRequestTypePortability:
RightsRequestTypeRectification,
RightsRequestTypePortability,
RightsRequestTypeObjection,
RightsRequestTypeComplaint:
return true
}

View File

@@ -240,6 +240,98 @@ WHERE
return nil
}
func (rrs *RightsRequests) CountByOrganizationIDAndContact(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
contact string,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
rights_requests
WHERE
%s
AND organization_id = @organization_id
AND contact = @contact
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
"contact": contact,
}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count rights requests: %w", err)
}
return count, nil
}
func (rrs *RightsRequests) LoadByOrganizationIDAndContact(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
contact string,
cursor *page.Cursor[RightsRequestOrderField],
) error {
q := `
SELECT
id,
organization_id,
request_type,
request_state,
data_subject,
contact,
details,
deadline,
action_taken,
created_at,
updated_at
FROM
rights_requests
WHERE
%s
AND organization_id = @organization_id
AND contact = @contact
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
"contact": contact,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query rights requests: %w", err)
}
requests, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RightsRequest])
if err != nil {
return fmt.Errorf("cannot collect rights requests: %w", err)
}
*rrs = requests
return nil
}
func (rr *RightsRequest) Insert(
ctx context.Context,
conn pg.Tx,

View File

@@ -8,10 +8,22 @@ enum RightsRequestType
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion"
)
RECTIFICATION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeRectification"
)
PORTABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
)
OBJECTION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeObjection"
)
COMPLAINT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeComplaint"
)
}
enum RightsRequestState
@@ -24,6 +36,10 @@ enum RightsRequestState
)
DONE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
REJECTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateRejected"
)
}
enum RightsRequestOrderField

View File

@@ -8489,7 +8489,10 @@ components:
enum:
- ACCESS
- DELETION
- RECTIFICATION
- PORTABILITY
- OBJECTION
- COMPLAINT
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestType
RightsRequestState:
@@ -8498,6 +8501,7 @@ components:
- TODO
- IN_PROGRESS
- DONE
- REJECTED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestState
RightsRequestOrderField:

View File

@@ -0,0 +1,83 @@
enum RightsRequestType
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestType") {
ACCESS @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeAccess")
DELETION
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion")
RECTIFICATION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeRectification"
)
PORTABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
)
OBJECTION
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeObjection")
COMPLAINT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeComplaint")
}
enum RightsRequestState
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestState") {
TODO @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateTodo")
IN_PROGRESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateInProgress"
)
DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
REJECTED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateRejected")
}
type RightsRequest implements Node {
id: ID!
requestType: RightsRequestType!
requestState: RightsRequestState!
dataSubject: String
contact: String
details: String
deadline: Datetime
actionTaken: String
createdAt: Datetime!
updatedAt: Datetime!
}
type RightsRequestConnection {
edges: [RightsRequestEdge!]!
pageInfo: PageInfo!
}
type RightsRequestEdge {
cursor: CursorKey!
node: RightsRequest!
}
extend type Query {
# The current viewer's own data subject requests for this trust center,
# scoped by their verified email. Returns an empty connection for guests so
# the portal can still render its empty state.
myRightsRequests(
first: Int
after: CursorKey
last: Int
before: CursorKey
): RightsRequestConnection!
}
extend type Mutation {
# Submit a data subject request. Requires a verified viewer; the request is
# attributed to the viewer's email, so no NDA gate applies.
createRightsRequest(
input: CreateRightsRequestInput!
): CreateRightsRequestPayload! @authentication(required: PRESENT)
}
input CreateRightsRequestInput {
requestType: RightsRequestType!
dataSubject: String
details: String
}
type CreateRightsRequestPayload {
rightsRequestEdge: RightsRequestEdge!
}

View File

@@ -0,0 +1,85 @@
package trust_v1
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.93
import (
"context"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/compliancepage"
"go.probo.inc/probo/pkg/server/api/trust/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/trust"
)
// CreateRightsRequest is the resolver for the createRightsRequest field.
func (r *mutationResolver) CreateRightsRequest(ctx context.Context, input types.CreateRightsRequestInput) (*types.CreateRightsRequestPayload, error) {
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to submit a request")
}
compliancePage := compliancepage.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
rightsRequest, err := r.trust.RightsRequests.Create(
ctx,
scope,
&trust.CreateRightsRequest{
OrganizationID: compliancePage.OrganizationID,
RequestType: input.RequestType,
DataSubject: input.DataSubject,
Contact: identity.EmailAddress.String(),
Details: input.Details,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create rights request", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateRightsRequestPayload{
RightsRequestEdge: types.NewRightsRequestEdge(
rightsRequest,
coredata.RightsRequestOrderFieldCreatedAt,
),
}, nil
}
// MyRightsRequests is the resolver for the myRightsRequests field.
func (r *queryResolver) MyRightsRequests(ctx context.Context, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.RightsRequestConnection, error) {
pageOrderBy := page.OrderBy[coredata.RightsRequestOrderField]{
Field: coredata.RightsRequestOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
identity := authn.IdentityFromContext(ctx)
if identity == nil {
emptyPage := page.NewPage([]*coredata.RightsRequest{}, cursor)
return types.NewRightsRequestConnection(emptyPage), nil
}
compliancePage := compliancepage.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
result, err := r.trust.RightsRequests.ListForOrganizationIDAndContact(
ctx,
scope,
compliancePage.OrganizationID,
identity.EmailAddress.String(),
cursor,
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list rights requests", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewRightsRequestConnection(result), nil
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2025-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.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
)
func NewRightsRequest(rr *coredata.RightsRequest) *RightsRequest {
return &RightsRequest{
ID: rr.ID,
RequestType: rr.RequestType,
RequestState: rr.RequestState,
DataSubject: rr.DataSubject,
Contact: rr.Contact,
Details: rr.Details,
Deadline: rr.Deadline,
ActionTaken: rr.ActionTaken,
CreatedAt: rr.CreatedAt,
UpdatedAt: rr.UpdatedAt,
}
}
func NewRightsRequestEdge(
rr *coredata.RightsRequest,
orderBy coredata.RightsRequestOrderField,
) *RightsRequestEdge {
return &RightsRequestEdge{
Cursor: rr.CursorKey(orderBy),
Node: NewRightsRequest(rr),
}
}
func NewRightsRequestConnection(
p *page.Page[*coredata.RightsRequest, coredata.RightsRequestOrderField],
) *RightsRequestConnection {
edges := make([]*RightsRequestEdge, len(p.Data))
for i, item := range p.Data {
edges[i] = NewRightsRequestEdge(item, p.Cursor.OrderBy.Field)
}
return &RightsRequestConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2025-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.
package trust
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
// RightsRequestDeadlineDays is the number of days a portal-submitted data
// subject request is given before its response deadline. Thirty days matches
// the GDPR Article 12(3) one-month standard (the shorter of GDPR / CCPA), and
// the console can adjust it afterwards.
const RightsRequestDeadlineDays = 30
type (
RightsRequestService struct {
svc *Service
}
// CreateRightsRequest is a data subject request submitted from the trust
// portal. The organization comes from the current compliance page and the
// contact from the verified viewer's identity, so neither is client-supplied.
CreateRightsRequest struct {
OrganizationID gid.GID
RequestType coredata.RightsRequestType
DataSubject *string
Contact string
Details *string
}
)
func (s *RightsRequestService) Create(
ctx context.Context,
scope coredata.Scoper,
req *CreateRightsRequest,
) (*coredata.RightsRequest, error) {
now := time.Now()
deadline := now.AddDate(0, 0, RightsRequestDeadlineDays)
request := &coredata.RightsRequest{
ID: gid.New(scope.GetTenantID(), coredata.RightsRequestEntityType),
OrganizationID: req.OrganizationID,
RequestType: req.RequestType,
RequestState: coredata.RightsRequestStateTodo,
DataSubject: req.DataSubject,
Contact: &req.Contact,
Details: req.Details,
Deadline: &deadline,
CreatedAt: now,
UpdatedAt: now,
}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
if err := request.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert rights request: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return request, nil
}
func (s RightsRequestService) CountForOrganizationIDAndContact(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
contact string,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
requests := coredata.RightsRequests{}
count, err = requests.CountByOrganizationIDAndContact(ctx, conn, scope, organizationID, contact)
if err != nil {
return fmt.Errorf("cannot count rights requests: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s RightsRequestService) ListForOrganizationIDAndContact(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
contact string,
cursor *page.Cursor[coredata.RightsRequestOrderField],
) (*page.Page[*coredata.RightsRequest, coredata.RightsRequestOrderField], error) {
var requests coredata.RightsRequests
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
err := requests.LoadByOrganizationIDAndContact(ctx, conn, scope, organizationID, contact, cursor)
if err != nil {
return fmt.Errorf("cannot load rights requests: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(requests, cursor), nil
}

View File

@@ -73,6 +73,7 @@ type (
Reports *ReportService
Organizations *OrganizationService
ComplianceExternalURLs *ComplianceExternalURLService
RightsRequests *RightsRequestService
resourceAlias *resourcealias.Service
}
)
@@ -119,6 +120,7 @@ func NewService(
svc.Reports = &ReportService{svc: svc}
svc.Organizations = &OrganizationService{svc: svc}
svc.ComplianceExternalURLs = &ComplianceExternalURLService{svc: svc}
svc.RightsRequests = &RightsRequestService{svc: svc}
return svc
}