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"
|
||||
}
|
||||
},
|
||||
"requests": {
|
||||
"title": "Data Requests",
|
||||
"newRequest": "New Request"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page not found",
|
||||
"description": "The page you are looking for does not exist or has moved.",
|
||||
|
||||
@@ -81,10 +81,6 @@
|
||||
"viewAll": "Voir tout"
|
||||
}
|
||||
},
|
||||
"requests": {
|
||||
"title": "Demandes de données",
|
||||
"newRequest": "Nouvelle demande"
|
||||
},
|
||||
"notFound": {
|
||||
"title": "Page introuvable",
|
||||
"description": "La page que vous recherchez n'existe pas ou a été déplacée.",
|
||||
|
||||
@@ -30,6 +30,9 @@ export const REQUEST_ALL_PARAM = "request-all";
|
||||
export const REQUEST_DOCUMENT_PARAM = "request-document-id";
|
||||
export const REQUEST_REPORT_PARAM = "request-report-id";
|
||||
export const REQUEST_FILE_PARAM = "request-file-id";
|
||||
// Marker that re-opens the "New Request" dialog once the user lands back
|
||||
// authenticated (the data request form gates on sign-in before it opens).
|
||||
export const NEW_REQUEST_PARAM = "new-request";
|
||||
|
||||
// Validates a `continue` target before we navigate to it. Only same-origin URLs
|
||||
// under the portal's path prefix are accepted; anything else falls back to the
|
||||
@@ -73,6 +76,14 @@ export function buildRequestAccessContinueUrl(param: string, id: string): string
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Absolute URL of the current page with the new-request marker set, so the data
|
||||
// request dialog re-opens after sign-in.
|
||||
export function buildNewRequestContinueUrl(): string {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(NEW_REQUEST_PARAM, "true");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Maps a caught auth-gate error to the route that resolves it, carrying the
|
||||
// given `continueUrl` so the user returns here (and any deferred request
|
||||
// resumes) once the gate is cleared. Returns null for non-gate errors. Shared
|
||||
|
||||
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
|
||||
// SOFTWARE.
|
||||
|
||||
import { PlusIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import type { AppRoute } from "@probo/routes";
|
||||
|
||||
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
||||
import { RequestsPageSkeleton } from "./RequestsPageSkeleton";
|
||||
|
||||
export default function RequestsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PageHeader
|
||||
title={t("requests.title")}
|
||||
actions={(
|
||||
<Button variant="soft" color="neutral" highContrast iconStart={<PlusIcon />}>
|
||||
{t("requests.newRequest")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const requestRoutes = [
|
||||
{
|
||||
path: "requests",
|
||||
Fallback: RequestsPageSkeleton,
|
||||
Component: lazy(() => import("./RequestsPageLoader")),
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
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 { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
||||
import { ndaRoutes } from "#/pages/nda/routes";
|
||||
import { requestRoutes } from "#/pages/requests/routes";
|
||||
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
|
||||
import { updateRoutes } from "#/pages/updates/routes";
|
||||
|
||||
@@ -54,10 +55,7 @@ const routes = [
|
||||
...documentRoutes,
|
||||
...subprocessorRoutes,
|
||||
...updateRoutes,
|
||||
{
|
||||
path: "requests",
|
||||
Component: lazy(() => import("#/pages/RequestsPage")),
|
||||
},
|
||||
...requestRoutes,
|
||||
{
|
||||
path: "*",
|
||||
Component: lazy(() => import("#/pages/NotFoundPage")),
|
||||
|
||||
@@ -65,8 +65,8 @@ import {
|
||||
} from "../../../hooks/graph/RightsRequestGraph";
|
||||
|
||||
const updateRequestSchema = z.object({
|
||||
requestType: z.enum(["ACCESS", "DELETION", "PORTABILITY"]),
|
||||
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE"]),
|
||||
requestType: z.enum(["ACCESS", "DELETION", "RECTIFICATION", "PORTABILITY", "OBJECTION", "COMPLAINT"]),
|
||||
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE", "REJECTED"]),
|
||||
dataSubject: z.string().optional(),
|
||||
contact: z.string().optional(),
|
||||
details: z.string().optional(),
|
||||
|
||||
@@ -50,8 +50,8 @@ import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useCreateRightsRequest } from "../../../../hooks/graph/RightsRequestGraph";
|
||||
|
||||
const schema = z.object({
|
||||
requestType: z.enum(["ACCESS", "DELETION", "PORTABILITY"]),
|
||||
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE"]),
|
||||
requestType: z.enum(["ACCESS", "DELETION", "RECTIFICATION", "PORTABILITY", "OBJECTION", "COMPLAINT"]),
|
||||
requestState: z.enum(["TODO", "IN_PROGRESS", "DONE", "REJECTED"]),
|
||||
dataSubject: z.string().optional(),
|
||||
contact: z.string().optional(),
|
||||
details: z.string().optional(),
|
||||
|
||||
@@ -20,43 +20,49 @@
|
||||
|
||||
type Translator = (s: string) => string;
|
||||
|
||||
export type RightsRequestType = "ACCESS" | "DELETION" | "PORTABILITY";
|
||||
export type RightsRequestType =
|
||||
| "ACCESS"
|
||||
| "DELETION"
|
||||
| "RECTIFICATION"
|
||||
| "PORTABILITY"
|
||||
| "OBJECTION"
|
||||
| "COMPLAINT";
|
||||
|
||||
export const rightsRequestTypes = [
|
||||
"ACCESS",
|
||||
"DELETION",
|
||||
"RECTIFICATION",
|
||||
"PORTABILITY",
|
||||
"OBJECTION",
|
||||
"COMPLAINT",
|
||||
] as const;
|
||||
|
||||
export type RightsRequestState = "TODO" | "IN_PROGRESS" | "DONE";
|
||||
export type RightsRequestState = "TODO" | "IN_PROGRESS" | "DONE" | "REJECTED";
|
||||
|
||||
export const rightsRequestStates = [
|
||||
"TODO",
|
||||
"IN_PROGRESS",
|
||||
"DONE",
|
||||
"REJECTED",
|
||||
] as const;
|
||||
|
||||
const rightsRequestTypeLabels: Record<RightsRequestType, string> = {
|
||||
"ACCESS": "Access",
|
||||
"DELETION": "Deletion",
|
||||
"RECTIFICATION": "Rectification",
|
||||
"PORTABILITY": "Portability",
|
||||
"OBJECTION": "Objection",
|
||||
"COMPLAINT": "Complaint",
|
||||
};
|
||||
|
||||
export function getRightsRequestTypeLabel(__: Translator, type: RightsRequestType) {
|
||||
switch (type) {
|
||||
case "ACCESS":
|
||||
return __("Access");
|
||||
case "DELETION":
|
||||
return __("Deletion");
|
||||
case "PORTABILITY":
|
||||
return __("Portability");
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
return __(rightsRequestTypeLabels[type] ?? type);
|
||||
}
|
||||
|
||||
export function getRightsRequestTypeOptions(__: Translator) {
|
||||
return rightsRequestTypes.map((type) => ({
|
||||
value: type,
|
||||
label: __({
|
||||
"ACCESS": "Access",
|
||||
"DELETION": "Deletion",
|
||||
"PORTABILITY": "Portability",
|
||||
}[type]),
|
||||
label: __(rightsRequestTypeLabels[type]),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -70,31 +76,27 @@ export const getRightsRequestStateVariant = (
|
||||
return "info" as const;
|
||||
case "DONE":
|
||||
return "success" as const;
|
||||
case "REJECTED":
|
||||
return "danger" as const;
|
||||
default:
|
||||
return "neutral" as const;
|
||||
}
|
||||
};
|
||||
|
||||
const rightsRequestStateLabels: Record<RightsRequestState, string> = {
|
||||
"TODO": "To Do",
|
||||
"IN_PROGRESS": "In Progress",
|
||||
"DONE": "Done",
|
||||
"REJECTED": "Rejected",
|
||||
};
|
||||
|
||||
export function getRightsRequestStateLabel(__: Translator, state: RightsRequestState) {
|
||||
switch (state) {
|
||||
case "TODO":
|
||||
return __("To Do");
|
||||
case "IN_PROGRESS":
|
||||
return __("In Progress");
|
||||
case "DONE":
|
||||
return __("Done");
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
return __(rightsRequestStateLabels[state] ?? state);
|
||||
}
|
||||
|
||||
export function getRightsRequestStateOptions(__: Translator) {
|
||||
return rightsRequestStates.map((state) => ({
|
||||
value: state,
|
||||
label: __({
|
||||
"TODO": "To Do",
|
||||
"IN_PROGRESS": "In Progress",
|
||||
"DONE": "Done",
|
||||
}[state]),
|
||||
label: __(rightsRequestStateLabels[state]),
|
||||
}));
|
||||
}
|
||||
|
||||
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({
|
||||
base: "inline-block animate-pulse rounded-2 bg-sand-3 align-middle",
|
||||
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"
|
||||
RightsRequestStateInProgress RightsRequestState = "IN_PROGRESS"
|
||||
RightsRequestStateDone RightsRequestState = "DONE"
|
||||
RightsRequestStateRejected RightsRequestState = "REJECTED"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -44,6 +45,7 @@ func RightsRequestStates() []RightsRequestState {
|
||||
RightsRequestStateTodo,
|
||||
RightsRequestStateInProgress,
|
||||
RightsRequestStateDone,
|
||||
RightsRequestStateRejected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +54,8 @@ func (v RightsRequestState) IsValid() bool {
|
||||
case
|
||||
RightsRequestStateTodo,
|
||||
RightsRequestStateInProgress,
|
||||
RightsRequestStateDone:
|
||||
RightsRequestStateDone,
|
||||
RightsRequestStateRejected:
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,12 @@ import (
|
||||
type RightsRequestType string
|
||||
|
||||
const (
|
||||
RightsRequestTypeAccess RightsRequestType = "ACCESS"
|
||||
RightsRequestTypeDeletion RightsRequestType = "DELETION"
|
||||
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
|
||||
RightsRequestTypeAccess RightsRequestType = "ACCESS"
|
||||
RightsRequestTypeDeletion RightsRequestType = "DELETION"
|
||||
RightsRequestTypeRectification RightsRequestType = "RECTIFICATION"
|
||||
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
|
||||
RightsRequestTypeObjection RightsRequestType = "OBJECTION"
|
||||
RightsRequestTypeComplaint RightsRequestType = "COMPLAINT"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -43,7 +46,10 @@ func RightsRequestTypes() []RightsRequestType {
|
||||
return []RightsRequestType{
|
||||
RightsRequestTypeAccess,
|
||||
RightsRequestTypeDeletion,
|
||||
RightsRequestTypeRectification,
|
||||
RightsRequestTypePortability,
|
||||
RightsRequestTypeObjection,
|
||||
RightsRequestTypeComplaint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +58,10 @@ func (v RightsRequestType) IsValid() bool {
|
||||
case
|
||||
RightsRequestTypeAccess,
|
||||
RightsRequestTypeDeletion,
|
||||
RightsRequestTypePortability:
|
||||
RightsRequestTypeRectification,
|
||||
RightsRequestTypePortability,
|
||||
RightsRequestTypeObjection,
|
||||
RightsRequestTypeComplaint:
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,98 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rrs *RightsRequests) CountByOrganizationIDAndContact(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
contact string,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(id)
|
||||
FROM
|
||||
rights_requests
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND contact = @contact
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
"contact": contact,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count rights requests: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (rrs *RightsRequests) LoadByOrganizationIDAndContact(
|
||||
ctx context.Context,
|
||||
conn pg.Querier,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
contact string,
|
||||
cursor *page.Cursor[RightsRequestOrderField],
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
request_type,
|
||||
request_state,
|
||||
data_subject,
|
||||
contact,
|
||||
details,
|
||||
deadline,
|
||||
action_taken,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
rights_requests
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND contact = @contact
|
||||
AND %s
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
"contact": contact,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query rights requests: %w", err)
|
||||
}
|
||||
|
||||
requests, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RightsRequest])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect rights requests: %w", err)
|
||||
}
|
||||
|
||||
*rrs = requests
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rr *RightsRequest) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
|
||||
@@ -8,10 +8,22 @@ enum RightsRequestType
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion"
|
||||
)
|
||||
RECTIFICATION
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeRectification"
|
||||
)
|
||||
PORTABILITY
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
|
||||
)
|
||||
OBJECTION
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeObjection"
|
||||
)
|
||||
COMPLAINT
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeComplaint"
|
||||
)
|
||||
}
|
||||
|
||||
enum RightsRequestState
|
||||
@@ -24,6 +36,10 @@ enum RightsRequestState
|
||||
)
|
||||
DONE
|
||||
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
|
||||
REJECTED
|
||||
@goEnum(
|
||||
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateRejected"
|
||||
)
|
||||
}
|
||||
|
||||
enum RightsRequestOrderField
|
||||
|
||||
@@ -8489,7 +8489,10 @@ components:
|
||||
enum:
|
||||
- ACCESS
|
||||
- DELETION
|
||||
- RECTIFICATION
|
||||
- PORTABILITY
|
||||
- OBJECTION
|
||||
- COMPLAINT
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestType
|
||||
|
||||
RightsRequestState:
|
||||
@@ -8498,6 +8501,7 @@ components:
|
||||
- TODO
|
||||
- IN_PROGRESS
|
||||
- DONE
|
||||
- REJECTED
|
||||
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestState
|
||||
|
||||
RightsRequestOrderField:
|
||||
|
||||
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
|
||||
Organizations *OrganizationService
|
||||
ComplianceExternalURLs *ComplianceExternalURLService
|
||||
RightsRequests *RightsRequestService
|
||||
resourceAlias *resourcealias.Service
|
||||
}
|
||||
)
|
||||
@@ -119,6 +120,7 @@ func NewService(
|
||||
svc.Reports = &ReportService{svc: svc}
|
||||
svc.Organizations = &OrganizationService{svc: svc}
|
||||
svc.ComplianceExternalURLs = &ComplianceExternalURLService{svc: svc}
|
||||
svc.RightsRequests = &RightsRequestService{svc: svc}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user