Add data request pages to compliance portal

Let trust-portal data subjects submit and track GDPR/CCPA rights
requests. The new Data Requests page lists the viewer's own requests
and a dialog submits new ones, scoped server-side to the verified
viewer email so former or inactive users can still exercise their
rights. Submission requires magic-link sign-in (reusing the existing
gate) but not the NDA gate.

Extend the shared rights_request enums with RECTIFICATION, OBJECTION
and COMPLAINT types plus a REJECTED state, and keep the console
GraphQL, @probo/helpers and the MCP specification in sync. Expose a
trust GraphQL surface (myRightsRequests query, createRightsRequest
mutation) backed by a trust service and contact-scoped coredata
loaders.

Add the missing v2 UI kit primitives the dialog needs on top of Base
UI: a SegmentedControl radio-cards group, a form Textarea, and a
Field wrapper.

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

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group";
import type { ReactNode } from "react";
import { segmentedControl } from "./variants";
export type SegmentedControlProps = {
// Single selected value (controlled).
value?: string;
// Single selected value (uncontrolled).
defaultValue?: string;
// Fired with the newly selected value. Never fired with an empty selection,
// so a value always stays selected (clicking the active item is a no-op).
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
children?: ReactNode;
};
// Single-select pill group. Wraps Base UI's array-based ToggleGroup with a
// friendlier single-value API.
export function SegmentedControl(props: SegmentedControlProps) {
const { value, defaultValue, onValueChange, disabled, className, children } = props;
const { root } = segmentedControl();
return (
<BaseToggleGroup
className={root({ className })}
disabled={disabled}
value={value != null ? [value] : undefined}
defaultValue={defaultValue != null ? [defaultValue] : undefined}
onValueChange={(groupValue) => {
const next = groupValue[0];
if (next != null) {
onValueChange?.(next);
}
}}
>
{children}
</BaseToggleGroup>
);
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Toggle as BaseToggle } from "@base-ui/react/toggle";
import type { ComponentProps } from "react";
import { segmentedControl } from "./variants";
export type SegmentedControlItemProps
= & Omit<ComponentProps<typeof BaseToggle>, "className">
& {
className?: string;
// Identifies this item within the group; matched against the group value.
value: string;
};
// A single segment. Pressed state is driven by Base UI (`data-pressed`).
export function SegmentedControlItem(props: SegmentedControlItemProps) {
const { className, ...rest } = props;
const { item } = segmentedControl();
return <BaseToggle className={item({ className })} {...rest} />;
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { tv } from "tailwind-variants/lite";
// Segmented control (single-select radio-cards over Base UI's ToggleGroup).
// There is no surrounding track: the items are standalone bordered cards laid
// out on an equal-width grid that wraps to new rows. Using a grid (rather than
// flex-wrap) keeps every card the same width and prevents a lone wrapped item
// from stretching across its row. Each card keeps a 1px border at all sizes
// (the pressed state only darkens the border, so selection never shifts layout).
export const segmentedControl = tv({
slots: {
root: "grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-1",
item: [
"min-w-0 cursor-pointer select-none rounded-3 border border-sand-a6 bg-sand-1 px-4 py-3.5",
"text-center text-2 font-medium text-sand-12 outline-none transition-colors",
"hover:border-sand-a8",
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
"data-pressed:border-sand-a12",
"data-disabled:pointer-events-none data-disabled:opacity-50",
],
},
});

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import type { ReactNode } from "react";
import { field } from "./variants";
export type FieldProps = {
// Text shown above the control. The control is nested inside the <label> so
// the association is implicit (no htmlFor / id threading required).
label?: ReactNode;
// Validation / server error shown below the control.
error?: ReactNode;
className?: string;
children: ReactNode;
};
// Vertical label + control + error grouping for form dialogs.
export function Field(props: FieldProps) {
const { label, error, className, children } = props;
const { root, label: labelSlot, labelText, error: errorSlot } = field();
return (
<div className={root({ className })}>
<label className={labelSlot()}>
{label != null && <span className={labelText()}>{label}</span>}
{children}
</label>
{error != null && <p className={errorSlot()}>{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import type { ComponentProps } from "react";
import { textArea } from "./variants";
export type TextareaProps
= & Omit<ComponentProps<"textarea">, "className">
& {
// Applied to the bordered container (the top-level element).
className?: string;
// Surface treatment (defaults to "surface").
variant?: "classic" | "surface" | "soft";
};
// Multi-line text input on a bordered surface mirroring TextField. The container
// is the top-level node; all native textarea props spread onto the inner control.
export function Textarea(props: TextareaProps) {
const { className, variant, rows = 4, ...textareaProps } = props;
const { root, textarea } = textArea({ variant });
return (
<div className={root({ className })}>
<textarea className={textarea()} rows={rows} {...textareaProps} />
</div>
);
}

View File

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