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(),
|
||||
|
||||
Reference in New Issue
Block a user