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:
@@ -81,10 +81,6 @@
|
|||||||
"viewAll": "View all"
|
"viewAll": "View all"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requests": {
|
|
||||||
"title": "Data Requests",
|
|
||||||
"newRequest": "New Request"
|
|
||||||
},
|
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Page not found",
|
"title": "Page not found",
|
||||||
"description": "The page you are looking for does not exist or has moved.",
|
"description": "The page you are looking for does not exist or has moved.",
|
||||||
|
|||||||
@@ -81,10 +81,6 @@
|
|||||||
"viewAll": "Voir tout"
|
"viewAll": "Voir tout"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requests": {
|
|
||||||
"title": "Demandes de données",
|
|
||||||
"newRequest": "Nouvelle demande"
|
|
||||||
},
|
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"title": "Page introuvable",
|
"title": "Page introuvable",
|
||||||
"description": "La page que vous recherchez n'existe pas ou a été déplacée.",
|
"description": "La page que vous recherchez n'existe pas ou a été déplacée.",
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export const REQUEST_ALL_PARAM = "request-all";
|
|||||||
export const REQUEST_DOCUMENT_PARAM = "request-document-id";
|
export const REQUEST_DOCUMENT_PARAM = "request-document-id";
|
||||||
export const REQUEST_REPORT_PARAM = "request-report-id";
|
export const REQUEST_REPORT_PARAM = "request-report-id";
|
||||||
export const REQUEST_FILE_PARAM = "request-file-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
|
// 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
|
// 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();
|
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
|
// 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
|
// given `continueUrl` so the user returns here (and any deferred request
|
||||||
// resumes) once the gate is cleared. Returns null for non-gate errors. Shared
|
// resumes) once the gate is cleared. Returns null for non-gate errors. Shared
|
||||||
|
|||||||
185
apps/compliance-portal/src/pages/requests/RequestsPage.tsx
Normal file
185
apps/compliance-portal/src/pages/requests/RequestsPage.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
104
apps/compliance-portal/src/pages/requests/_lib/rightsRequest.ts
Normal file
104
apps/compliance-portal/src/pages/requests/_lib/rightsRequest.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,22 +18,15 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { PlusIcon } from "@phosphor-icons/react";
|
import { lazy } from "@probo/react-lazy";
|
||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import type { AppRoute } from "@probo/routes";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
import { RequestsPageSkeleton } from "./RequestsPageSkeleton";
|
||||||
|
|
||||||
export default function RequestsPage() {
|
export const requestRoutes = [
|
||||||
const { t } = useTranslation();
|
{
|
||||||
return (
|
path: "requests",
|
||||||
<PageHeader
|
Fallback: RequestsPageSkeleton,
|
||||||
title={t("requests.title")}
|
Component: lazy(() => import("./RequestsPageLoader")),
|
||||||
actions={(
|
},
|
||||||
<Button variant="soft" color="neutral" highContrast iconStart={<PlusIcon />}>
|
] satisfies AppRoute[];
|
||||||
{t("requests.newRequest")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
30
apps/compliance-portal/src/pages/requests/variants.ts
Normal file
30
apps/compliance-portal/src/pages/requests/variants.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@ import { documentRoutes } from "#/pages/documents/routes";
|
|||||||
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
||||||
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
||||||
import { ndaRoutes } from "#/pages/nda/routes";
|
import { ndaRoutes } from "#/pages/nda/routes";
|
||||||
|
import { requestRoutes } from "#/pages/requests/routes";
|
||||||
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
|
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
|
||||||
import { updateRoutes } from "#/pages/updates/routes";
|
import { updateRoutes } from "#/pages/updates/routes";
|
||||||
|
|
||||||
@@ -54,10 +55,7 @@ const routes = [
|
|||||||
...documentRoutes,
|
...documentRoutes,
|
||||||
...subprocessorRoutes,
|
...subprocessorRoutes,
|
||||||
...updateRoutes,
|
...updateRoutes,
|
||||||
{
|
...requestRoutes,
|
||||||
path: "requests",
|
|
||||||
Component: lazy(() => import("#/pages/RequestsPage")),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
Component: lazy(() => import("#/pages/NotFoundPage")),
|
Component: lazy(() => import("#/pages/NotFoundPage")),
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ import {
|
|||||||
} from "../../../hooks/graph/RightsRequestGraph";
|
} from "../../../hooks/graph/RightsRequestGraph";
|
||||||
|
|
||||||
const updateRequestSchema = z.object({
|
const updateRequestSchema = z.object({
|
||||||
requestType: z.enum(["ACCESS", "DELETION", "PORTABILITY"]),
|
requestType: z.enum(["ACCESS", "DELETION", "RECTIFICATION", "PORTABILITY", "OBJECTION", "COMPLAINT"]),
|
||||||
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE"]),
|
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE", "REJECTED"]),
|
||||||
dataSubject: z.string().optional(),
|
dataSubject: z.string().optional(),
|
||||||
contact: z.string().optional(),
|
contact: z.string().optional(),
|
||||||
details: z.string().optional(),
|
details: z.string().optional(),
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
|||||||
import { useCreateRightsRequest } from "../../../../hooks/graph/RightsRequestGraph";
|
import { useCreateRightsRequest } from "../../../../hooks/graph/RightsRequestGraph";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
requestType: z.enum(["ACCESS", "DELETION", "PORTABILITY"]),
|
requestType: z.enum(["ACCESS", "DELETION", "RECTIFICATION", "PORTABILITY", "OBJECTION", "COMPLAINT"]),
|
||||||
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE"]),
|
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE", "REJECTED"]),
|
||||||
dataSubject: z.string().optional(),
|
dataSubject: z.string().optional(),
|
||||||
contact: z.string().optional(),
|
contact: z.string().optional(),
|
||||||
details: z.string().optional(),
|
details: z.string().optional(),
|
||||||
|
|||||||
@@ -20,43 +20,49 @@
|
|||||||
|
|
||||||
type Translator = (s: string) => string;
|
type Translator = (s: string) => string;
|
||||||
|
|
||||||
export type RightsRequestType = "ACCESS" | "DELETION" | "PORTABILITY";
|
export type RightsRequestType =
|
||||||
|
| "ACCESS"
|
||||||
|
| "DELETION"
|
||||||
|
| "RECTIFICATION"
|
||||||
|
| "PORTABILITY"
|
||||||
|
| "OBJECTION"
|
||||||
|
| "COMPLAINT";
|
||||||
|
|
||||||
export const rightsRequestTypes = [
|
export const rightsRequestTypes = [
|
||||||
"ACCESS",
|
"ACCESS",
|
||||||
"DELETION",
|
"DELETION",
|
||||||
|
"RECTIFICATION",
|
||||||
"PORTABILITY",
|
"PORTABILITY",
|
||||||
|
"OBJECTION",
|
||||||
|
"COMPLAINT",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type RightsRequestState = "TODO" | "IN_PROGRESS" | "DONE";
|
export type RightsRequestState = "TODO" | "IN_PROGRESS" | "DONE" | "REJECTED";
|
||||||
|
|
||||||
export const rightsRequestStates = [
|
export const rightsRequestStates = [
|
||||||
"TODO",
|
"TODO",
|
||||||
"IN_PROGRESS",
|
"IN_PROGRESS",
|
||||||
"DONE",
|
"DONE",
|
||||||
|
"REJECTED",
|
||||||
] as const;
|
] 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) {
|
export function getRightsRequestTypeLabel(__: Translator, type: RightsRequestType) {
|
||||||
switch (type) {
|
return __(rightsRequestTypeLabels[type] ?? type);
|
||||||
case "ACCESS":
|
|
||||||
return __("Access");
|
|
||||||
case "DELETION":
|
|
||||||
return __("Deletion");
|
|
||||||
case "PORTABILITY":
|
|
||||||
return __("Portability");
|
|
||||||
default:
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRightsRequestTypeOptions(__: Translator) {
|
export function getRightsRequestTypeOptions(__: Translator) {
|
||||||
return rightsRequestTypes.map((type) => ({
|
return rightsRequestTypes.map((type) => ({
|
||||||
value: type,
|
value: type,
|
||||||
label: __({
|
label: __(rightsRequestTypeLabels[type]),
|
||||||
"ACCESS": "Access",
|
|
||||||
"DELETION": "Deletion",
|
|
||||||
"PORTABILITY": "Portability",
|
|
||||||
}[type]),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,31 +76,27 @@ export const getRightsRequestStateVariant = (
|
|||||||
return "info" as const;
|
return "info" as const;
|
||||||
case "DONE":
|
case "DONE":
|
||||||
return "success" as const;
|
return "success" as const;
|
||||||
|
case "REJECTED":
|
||||||
|
return "danger" as const;
|
||||||
default:
|
default:
|
||||||
return "neutral" as const;
|
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) {
|
export function getRightsRequestStateLabel(__: Translator, state: RightsRequestState) {
|
||||||
switch (state) {
|
return __(rightsRequestStateLabels[state] ?? state);
|
||||||
case "TODO":
|
|
||||||
return __("To Do");
|
|
||||||
case "IN_PROGRESS":
|
|
||||||
return __("In Progress");
|
|
||||||
case "DONE":
|
|
||||||
return __("Done");
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRightsRequestStateOptions(__: Translator) {
|
export function getRightsRequestStateOptions(__: Translator) {
|
||||||
return rightsRequestStates.map((state) => ({
|
return rightsRequestStates.map((state) => ({
|
||||||
value: state,
|
value: state,
|
||||||
label: __({
|
label: __(rightsRequestStateLabels[state]),
|
||||||
"TODO": "To Do",
|
|
||||||
"IN_PROGRESS": "In Progress",
|
|
||||||
"DONE": "Done",
|
|
||||||
}[state]),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
61
packages/ui/src/v2/SegmentedControl/SegmentedControl.tsx
Normal file
61
packages/ui/src/v2/SegmentedControl/SegmentedControl.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
packages/ui/src/v2/SegmentedControl/SegmentedControlItem.tsx
Normal file
40
packages/ui/src/v2/SegmentedControl/SegmentedControlItem.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
41
packages/ui/src/v2/SegmentedControl/variants.ts
Normal file
41
packages/ui/src/v2/SegmentedControl/variants.ts
Normal 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",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
49
packages/ui/src/v2/form/Field.tsx
Normal file
49
packages/ui/src/v2/form/Field.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
packages/ui/src/v2/form/Textarea.tsx
Normal file
45
packages/ui/src/v2/form/Textarea.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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({
|
export const textFieldSkeleton = tv({
|
||||||
base: "inline-block animate-pulse rounded-2 bg-sand-3 align-middle",
|
base: "inline-block animate-pulse rounded-2 bg-sand-3 align-middle",
|
||||||
variants: {
|
variants: {
|
||||||
|
|||||||
25
pkg/coredata/migrations/20260717T133746Z.sql
Normal file
25
pkg/coredata/migrations/20260717T133746Z.sql
Normal 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';
|
||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
RightsRequestStateTodo RightsRequestState = "TODO"
|
RightsRequestStateTodo RightsRequestState = "TODO"
|
||||||
RightsRequestStateInProgress RightsRequestState = "IN_PROGRESS"
|
RightsRequestStateInProgress RightsRequestState = "IN_PROGRESS"
|
||||||
RightsRequestStateDone RightsRequestState = "DONE"
|
RightsRequestStateDone RightsRequestState = "DONE"
|
||||||
|
RightsRequestStateRejected RightsRequestState = "REJECTED"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -44,6 +45,7 @@ func RightsRequestStates() []RightsRequestState {
|
|||||||
RightsRequestStateTodo,
|
RightsRequestStateTodo,
|
||||||
RightsRequestStateInProgress,
|
RightsRequestStateInProgress,
|
||||||
RightsRequestStateDone,
|
RightsRequestStateDone,
|
||||||
|
RightsRequestStateRejected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +54,8 @@ func (v RightsRequestState) IsValid() bool {
|
|||||||
case
|
case
|
||||||
RightsRequestStateTodo,
|
RightsRequestStateTodo,
|
||||||
RightsRequestStateInProgress,
|
RightsRequestStateInProgress,
|
||||||
RightsRequestStateDone:
|
RightsRequestStateDone,
|
||||||
|
RightsRequestStateRejected:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ import (
|
|||||||
type RightsRequestType string
|
type RightsRequestType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RightsRequestTypeAccess RightsRequestType = "ACCESS"
|
RightsRequestTypeAccess RightsRequestType = "ACCESS"
|
||||||
RightsRequestTypeDeletion RightsRequestType = "DELETION"
|
RightsRequestTypeDeletion RightsRequestType = "DELETION"
|
||||||
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
|
RightsRequestTypeRectification RightsRequestType = "RECTIFICATION"
|
||||||
|
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
|
||||||
|
RightsRequestTypeObjection RightsRequestType = "OBJECTION"
|
||||||
|
RightsRequestTypeComplaint RightsRequestType = "COMPLAINT"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -43,7 +46,10 @@ func RightsRequestTypes() []RightsRequestType {
|
|||||||
return []RightsRequestType{
|
return []RightsRequestType{
|
||||||
RightsRequestTypeAccess,
|
RightsRequestTypeAccess,
|
||||||
RightsRequestTypeDeletion,
|
RightsRequestTypeDeletion,
|
||||||
|
RightsRequestTypeRectification,
|
||||||
RightsRequestTypePortability,
|
RightsRequestTypePortability,
|
||||||
|
RightsRequestTypeObjection,
|
||||||
|
RightsRequestTypeComplaint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +58,10 @@ func (v RightsRequestType) IsValid() bool {
|
|||||||
case
|
case
|
||||||
RightsRequestTypeAccess,
|
RightsRequestTypeAccess,
|
||||||
RightsRequestTypeDeletion,
|
RightsRequestTypeDeletion,
|
||||||
RightsRequestTypePortability:
|
RightsRequestTypeRectification,
|
||||||
|
RightsRequestTypePortability,
|
||||||
|
RightsRequestTypeObjection,
|
||||||
|
RightsRequestTypeComplaint:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,98 @@ WHERE
|
|||||||
return nil
|
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(
|
func (rr *RightsRequest) Insert(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Tx,
|
conn pg.Tx,
|
||||||
|
|||||||
@@ -8,10 +8,22 @@ enum RightsRequestType
|
|||||||
@goEnum(
|
@goEnum(
|
||||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion"
|
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion"
|
||||||
)
|
)
|
||||||
|
RECTIFICATION
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeRectification"
|
||||||
|
)
|
||||||
PORTABILITY
|
PORTABILITY
|
||||||
@goEnum(
|
@goEnum(
|
||||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
|
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
|
enum RightsRequestState
|
||||||
@@ -24,6 +36,10 @@ enum RightsRequestState
|
|||||||
)
|
)
|
||||||
DONE
|
DONE
|
||||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
|
||||||
|
REJECTED
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateRejected"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RightsRequestOrderField
|
enum RightsRequestOrderField
|
||||||
|
|||||||
@@ -8489,7 +8489,10 @@ components:
|
|||||||
enum:
|
enum:
|
||||||
- ACCESS
|
- ACCESS
|
||||||
- DELETION
|
- DELETION
|
||||||
|
- RECTIFICATION
|
||||||
- PORTABILITY
|
- PORTABILITY
|
||||||
|
- OBJECTION
|
||||||
|
- COMPLAINT
|
||||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestType
|
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestType
|
||||||
|
|
||||||
RightsRequestState:
|
RightsRequestState:
|
||||||
@@ -8498,6 +8501,7 @@ components:
|
|||||||
- TODO
|
- TODO
|
||||||
- IN_PROGRESS
|
- IN_PROGRESS
|
||||||
- DONE
|
- DONE
|
||||||
|
- REJECTED
|
||||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestState
|
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestState
|
||||||
|
|
||||||
RightsRequestOrderField:
|
RightsRequestOrderField:
|
||||||
|
|||||||
83
pkg/server/api/trust/v1/graphql/rights_request.graphql
Normal file
83
pkg/server/api/trust/v1/graphql/rights_request.graphql
Normal 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!
|
||||||
|
}
|
||||||
85
pkg/server/api/trust/v1/rights_request_resolvers.go
Normal file
85
pkg/server/api/trust/v1/rights_request_resolvers.go
Normal 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
|
||||||
|
}
|
||||||
66
pkg/server/api/trust/v1/types/rights_request.go
Normal file
66
pkg/server/api/trust/v1/types/rights_request.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
153
pkg/trust/rights_request_service.go
Normal file
153
pkg/trust/rights_request_service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ type (
|
|||||||
Reports *ReportService
|
Reports *ReportService
|
||||||
Organizations *OrganizationService
|
Organizations *OrganizationService
|
||||||
ComplianceExternalURLs *ComplianceExternalURLService
|
ComplianceExternalURLs *ComplianceExternalURLService
|
||||||
|
RightsRequests *RightsRequestService
|
||||||
resourceAlias *resourcealias.Service
|
resourceAlias *resourcealias.Service
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -119,6 +120,7 @@ func NewService(
|
|||||||
svc.Reports = &ReportService{svc: svc}
|
svc.Reports = &ReportService{svc: svc}
|
||||||
svc.Organizations = &OrganizationService{svc: svc}
|
svc.Organizations = &OrganizationService{svc: svc}
|
||||||
svc.ComplianceExternalURLs = &ComplianceExternalURLService{svc: svc}
|
svc.ComplianceExternalURLs = &ComplianceExternalURLService{svc: svc}
|
||||||
|
svc.RightsRequests = &RightsRequestService{svc: svc}
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user