Add configurable compliance portal commitment cards

The compliance portal home page rendered security-commitment cards from
a hardcoded placeholder POJO. Back them with real, per-organization data
that admins configure in the console and the portal loads over the trust
center GraphQL API.

Model two entities under the trust center: a commitment group (title,
description, rank) and a commitment card (icon, eyebrow, title,
description, rank). The card icon is a curated enum mapped to a Phosphor
icon in the portal. New entities adopt the compliance_portal_ prefix as
the start of the broader rename away from trust_center_ naming.

Expose the groups and cards read-only on the public trust API and with
full CRUD on the console API, add a Commitments tab to the compliance
page, and replace the placeholder section with a Relay-driven one.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-16 13:36:44 +02:00
parent 936162d5c7
commit 0b146a4054
39 changed files with 4079 additions and 175 deletions

View File

@@ -20,6 +20,7 @@
"heroDescription": "Welcome to our Compliance Portal. Find our security documentation and certifications here.",
"sections": {
"compliance": "Compliance",
"securityCommitments": "Security Commitments",
"trustedBy": "Trusted by",
"recentUpdates": "Recent updates"
},

View File

@@ -20,6 +20,7 @@
"heroDescription": "Bienvenue dans notre Compliance Portal. Retrouvez ici notre documentation et nos certifications de sécurité.",
"sections": {
"compliance": "Conformité",
"securityCommitments": "Engagements de sécurité",
"trustedBy": "Ils nous font confiance",
"recentUpdates": "Mises à jour récentes"
},

View File

@@ -0,0 +1,83 @@
// 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 { Text } from "@probo/ui/src/v2/typography/Text";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import type { SecurityCommitmentGroupListItem_group$key } from "./__generated__/SecurityCommitmentGroupListItem_group.graphql";
import { SecurityCommitmentListItem } from "./SecurityCommitmentListItem";
import { securityCommitments } from "./variants";
const fragment = graphql`
fragment SecurityCommitmentGroupListItem_group on CompliancePortalCommitmentGroup {
title
description
commitments(first: 100) {
edges {
node {
id
...SecurityCommitmentListItem_commitment
}
}
}
}
`;
interface SecurityCommitmentGroupListItemProps {
groupKey: SecurityCommitmentGroupListItem_group$key;
// Only the first group carries the section eyebrow, matching the design.
showEyebrow: boolean;
}
export function SecurityCommitmentGroupListItem({ groupKey, showEyebrow }: SecurityCommitmentGroupListItemProps) {
const { t } = useTranslation();
const group = useFragment(fragment, groupKey);
const slots = securityCommitments();
const commitments = group.commitments.edges.map(edge => edge.node);
if (commitments.length === 0) {
return null;
}
return (
<div className={slots.group()}>
<div className={slots.groupHeader()}>
{showEyebrow && (
<Text size={1} color="gold">
{t("home.sections.securityCommitments")}
</Text>
)}
<Text size={2} weight="medium" color="neutral" highContrast>
{group.title}
</Text>
<Text size={2} color="neutral">
{group.description}
</Text>
</div>
<div className={slots.grid()}>
{commitments.map(commitment => (
<SecurityCommitmentListItem key={commitment.id} commitmentKey={commitment} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
// 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 { Text } from "@probo/ui/src/v2/typography/Text";
import { graphql, useFragment } from "react-relay";
import { CommitmentCard } from "#/components/CommitmentCard/CommitmentCard";
import type { SecurityCommitmentListItem_commitment$key } from "./__generated__/SecurityCommitmentListItem_commitment.graphql";
import { CommitmentIcon } from "./commitmentIcons";
const fragment = graphql`
fragment SecurityCommitmentListItem_commitment on CompliancePortalCommitment {
icon
eyebrow
title
description
}
`;
interface SecurityCommitmentListItemProps {
commitmentKey: SecurityCommitmentListItem_commitment$key;
}
export function SecurityCommitmentListItem({ commitmentKey }: SecurityCommitmentListItemProps) {
const commitment = useFragment(fragment, commitmentKey);
return (
<CommitmentCard
icon={<CommitmentIcon icon={commitment.icon} size={32} weight="light" />}
eyebrow={<Text size={1} color="gold">{commitment.eyebrow}</Text>}
title={(
<Text size={4} weight="medium" color="neutral" highContrast>
{commitment.title}
</Text>
)}
description={<Text size={2} color="neutral">{commitment.description}</Text>}
/>
);
}

View File

@@ -18,54 +18,70 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Text } from "@probo/ui/src/v2/typography/Text";
import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary";
import { graphql, useFragment } from "react-relay";
import { CommitmentCard } from "#/components/CommitmentCard/CommitmentCard";
import { InlineErrorCard } from "#/components/errors/InlineErrorCard";
import { SECURITY_COMMITMENT_GROUPS } from "./securityCommitments";
import type { SecurityCommitmentsSection_trustCenter$key } from "./__generated__/SecurityCommitmentsSection_trustCenter.graphql";
import { SecurityCommitmentGroupListItem } from "./SecurityCommitmentGroupListItem";
import { securityCommitments } from "./variants";
// "Security Commitments" section.
//
// TODO: This section renders placeholder data from a local POJO
// (./securityCommitments.ts) because there is no backend / DB structure for it
// yet. Replace it with a relay-driven fragment (and i18n copy) once available.
export function SecurityCommitmentsSection() {
// @throwOnFieldError makes a field error in this fragment throw at the read
// below, where the section's ErrorBoundary contains it. See
// contrib/claude/error-handling.md.
const securityCommitmentsSectionFragment = graphql`
fragment SecurityCommitmentsSection_trustCenter on TrustCenter @throwOnFieldError {
commitmentGroups(first: 100) {
edges {
node {
id
...SecurityCommitmentGroupListItem_group
}
}
}
}
`;
interface SecurityCommitmentsSectionProps {
trustCenterKey: SecurityCommitmentsSection_trustCenter$key;
}
// "Security Commitments" section: stacked groups, each a header above a grid of
// commitment cards. Wraps its data-reading content in a boundary so a load
// failure degrades to an inline error instead of taking down the page.
export function SecurityCommitmentsSection({ trustCenterKey }: SecurityCommitmentsSectionProps) {
return (
<ErrorBoundary
fallback={(
<div className="w-full py-8">
<InlineErrorCard onRetry={() => window.location.reload()} />
</div>
)}
>
<SecurityCommitmentsSectionContent trustCenterKey={trustCenterKey} />
</ErrorBoundary>
);
}
function SecurityCommitmentsSectionContent({ trustCenterKey }: SecurityCommitmentsSectionProps) {
const data = useFragment(securityCommitmentsSectionFragment, trustCenterKey);
const slots = securityCommitments();
const groups = data.commitmentGroups.edges.map(edge => edge.node);
if (groups.length === 0) {
return null;
}
return (
<section className={slots.root()}>
{SECURITY_COMMITMENT_GROUPS.map(group => (
<div key={group.title} className={slots.group()}>
<div className={slots.groupHeader()}>
{group.eyebrow != null && (
<Text size={1} color="gold">
{group.eyebrow}
</Text>
)}
<Text size={2} weight="medium" color="neutral" highContrast>
{group.title}
</Text>
<Text size={2} color="neutral">
{group.description}
</Text>
</div>
<div className={slots.grid()}>
{group.items.map(item => (
<CommitmentCard
key={item.title}
icon={<item.Icon size={32} weight="light" />}
eyebrow={<Text size={1} color="gold">{item.eyebrow}</Text>}
title={(
<Text size={4} weight="medium" color="neutral" highContrast>
{item.title}
</Text>
)}
description={<Text size={2} color="neutral">{item.description}</Text>}
/>
))}
</div>
</div>
{groups.map((group, index) => (
<SecurityCommitmentGroupListItem
key={group.id}
groupKey={group}
showEyebrow={index === 0}
/>
))}
</section>
);

View File

@@ -0,0 +1,78 @@
// 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 { Icon, IconProps } from "@phosphor-icons/react";
import {
BellIcon,
BugIcon,
CertificateIcon,
CloudIcon,
CodeIcon,
DatabaseIcon,
EyeIcon,
EyeSlashIcon,
FingerprintIcon,
GavelIcon,
GlobeIcon,
HardDrivesIcon,
HeartbeatIcon,
KeyIcon,
LockIcon,
LockKeyIcon,
ShieldCheckIcon,
ShieldWarningIcon,
SirenIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { createElement } from "react";
// Maps the CompliancePortalCommitmentIcon enum (coredata) to the Phosphor icon
// rendered on each commitment card. Keep in sync with the console icon picker
// (see apps/console .../commitments/_lib/commitmentIcons.ts).
const COMMITMENT_ICONS: Record<string, Icon> = {
LOCK_KEY: LockKeyIcon,
EYE_SLASH: EyeSlashIcon,
FINGERPRINT: FingerprintIcon,
SHIELD_WARNING: ShieldWarningIcon,
SHIELD_CHECK: ShieldCheckIcon,
SIREN: SirenIcon,
KEY: KeyIcon,
LOCK: LockIcon,
CLOUD: CloudIcon,
DATABASE: DatabaseIcon,
GLOBE: GlobeIcon,
EYE: EyeIcon,
USERS: UsersIcon,
CERTIFICATE: CertificateIcon,
GAVEL: GavelIcon,
HEARTBEAT: HeartbeatIcon,
BELL: BellIcon,
BUG: BugIcon,
CODE: CodeIcon,
SERVER: HardDrivesIcon,
};
// Renders the Phosphor icon mapped from a CompliancePortalCommitmentIcon enum
// value. Declared at module scope (and built via createElement) so the icon
// component is never created during a parent's render.
export function CommitmentIcon({ icon, ...props }: { icon: string } & IconProps) {
const resolved: Icon = COMMITMENT_ICONS[icon] ?? LockKeyIcon;
return createElement(resolved, props);
}

View File

@@ -1,93 +0,0 @@
// 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 { Icon } from "@phosphor-icons/react";
import { EyeSlashIcon, FingerprintIcon, LockKeyIcon, ShieldWarningIcon, SirenIcon } from "@phosphor-icons/react";
export interface SecurityCommitmentItem {
eyebrow: string;
title: string;
description: string;
Icon: Icon;
}
export interface SecurityCommitmentGroup {
// Only the first group carries the section eyebrow in the design.
eyebrow?: string;
title: string;
description: string;
items: SecurityCommitmentItem[];
}
// TODO: This content is placeholder data. There is no backend / DB structure for
// security commitments yet — replace this POJO with relay-sourced data (and move
// the copy into i18n) once the schema exists.
export const SECURITY_COMMITMENT_GROUPS: SecurityCommitmentGroup[] = [
{
eyebrow: "Security Commitments",
title: "Data Protection",
description:
"Your data is protected with industry-leading encryption and strict privacy controls, from storage to transit to processing.",
items: [
{
eyebrow: "Data Protection",
title: "Encrypted at rest. Encrypted in transit. Always.",
description:
"Customer data is protected with AES-256 at rest and TLS 1.3 in transit. Keys rotated on a fixed schedule.",
Icon: LockKeyIcon,
},
{
eyebrow: "Privacy",
title: "Customer data stays customer data.",
description:
"PII is masked in non-production environments. Access requires SSO with hardware-backed keys.",
Icon: EyeSlashIcon,
},
{
eyebrow: "Identity",
title: "Phishing-resistant authentication, by default.",
description:
"Every employee authenticates via WebAuthn. Production access is just-in-time and recorded.",
Icon: FingerprintIcon,
},
],
},
{
title: "Operational Security",
description:
"We maintain continuous uptime and rapid incident response so you can rely on our platform around the clock.",
items: [
{
eyebrow: "Threat Detection",
title: "Anomalies do not wait. Neither do we.",
description:
"24/7 SIEM monitoring with automated triage. Critical incidents page on-call within 90 seconds.",
Icon: ShieldWarningIcon,
},
{
eyebrow: "Incident Response",
title: "Issues are caught fast. And handled.",
description:
"Security incidents are investigated within hours. Customers notified within 24 hours of a confirmed breach.",
Icon: SirenIcon,
},
],
},
];

View File

@@ -39,6 +39,7 @@ export const homePageQuery = graphql`
...OrganizationContactInfo_organization
}
...ComplianceFrameworksSection_trustCenter
...SecurityCommitmentsSection_trustCenter
...TrustedBySection_trustCenter
...RecentUpdatesSection_trustCenter
}
@@ -66,7 +67,7 @@ export function HomePage({ queryRef }: HomePageProps) {
<div className="flex w-full flex-col items-center px-8">
<div className="flex w-full max-w-5xl flex-col">
<ComplianceFrameworksSection trustCenterKey={currentTrustCenter} />
<SecurityCommitmentsSection />
<SecurityCommitmentsSection trustCenterKey={currentTrustCenter} />
<TrustedBySection trustCenterKey={currentTrustCenter} />
<RecentUpdatesSection trustCenterKey={currentTrustCenter} />
</div>

View File

@@ -20,7 +20,7 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Badge, Button, IconBell2, IconCheckmark1, IconFolder2, IconMedal, IconPageTextLine, IconPencil, IconPeopleAdd, IconSettingsGear2, IconStore, PageHeader, TabLink, Tabs } from "@probo/ui";
import { Badge, Button, IconBell2, IconCheckmark1, IconFolder2, IconMedal, IconPageTextLine, IconPencil, IconPeopleAdd, IconSettingsGear2, IconShield, IconStore, PageHeader, TabLink, Tabs } from "@probo/ui";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { Outlet } from "react-router";
import { graphql } from "relay-runtime";
@@ -107,6 +107,10 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
<IconCheckmark1 className="size-4" />
{__("References")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/compliance-page/commitments`}>
<IconShield className="size-4" />
{__("Commitments")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/compliance-page/audits`}>
<IconMedal className="size-4" />
{__("Audits")}

View File

@@ -0,0 +1,60 @@
// 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, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { CompliancePageCommitmentsPageQuery } from "#/__generated__/core/CompliancePageCommitmentsPageQuery.graphql";
import { CompliancePageCommitmentGroupList } from "./_components/CompliancePageCommitmentGroupList";
export const compliancePageCommitmentsPageQuery = graphql`
query CompliancePageCommitmentsPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
__typename
... on Organization {
compliancePage: trustCenter @required(action: THROW) {
id
canCreateGroup: permission(action: "core:compliance-portal-commitment-group:create")
...CompliancePageCommitmentGroupListFragment
}
}
}
}
`;
export function CompliancePageCommitmentsPage(props: { queryRef: PreloadedQuery<CompliancePageCommitmentsPageQuery> }) {
const { queryRef } = props;
const { organization } = usePreloadedQuery<CompliancePageCommitmentsPageQuery>(
compliancePageCommitmentsPageQuery,
queryRef,
);
if (organization.__typename !== "Organization") {
throw new Error("invalid type for node");
}
return (
<CompliancePageCommitmentGroupList
fragmentRef={organization.compliancePage}
trustCenterId={organization.compliancePage.id}
canCreate={organization.compliancePage.canCreateGroup}
/>
);
}

View File

@@ -0,0 +1,52 @@
// 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 { CompliancePageCommitmentsPageQuery } from "#/__generated__/core/CompliancePageCommitmentsPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import { CompliancePageCommitmentsPage, compliancePageCommitmentsPageQuery } from "./CompliancePageCommitmentsPage";
function CompliancePageCommitmentsPageQueryLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<CompliancePageCommitmentsPageQuery>(compliancePageCommitmentsPageQuery);
useEffect(() => {
if (!queryRef) {
loadQuery({ organizationId });
}
});
if (!queryRef) return <LinkCardSkeleton />;
return <CompliancePageCommitmentsPage queryRef={queryRef} />;
}
export default function CompliancePageCommitmentsPageLoader() {
return (
<CoreRelayProvider>
<CompliancePageCommitmentsPageQueryLoader />
</CoreRelayProvider>
);
}

View File

@@ -0,0 +1,208 @@
// 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 { useTranslate } from "@probo/i18n";
import { Button, Dialog, DialogContent, DialogFooter, Field, Label, Option, Spinner, Textarea, useDialogRef } from "@probo/ui";
import { forwardRef, useImperativeHandle, useState } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { CompliancePageCommitmentDialogCreateMutation, CompliancePortalCommitmentIcon } from "#/__generated__/core/CompliancePageCommitmentDialogCreateMutation.graphql";
import type { CompliancePageCommitmentDialogUpdateMutation } from "#/__generated__/core/CompliancePageCommitmentDialogUpdateMutation.graphql";
import type { CompliancePageCommitmentListItemFragment$data } from "#/__generated__/core/CompliancePageCommitmentListItemFragment.graphql";
import { ControlledSelect } from "#/components/form/ControlledField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { COMMITMENT_ICON_LABELS, COMMITMENT_ICON_VALUES } from "../_lib/commitmentIcons";
const createCommitmentMutation = graphql`
mutation CompliancePageCommitmentDialogCreateMutation(
$input: CreateCompliancePortalCommitmentInput!
) {
createCompliancePortalCommitment(input: $input) {
compliancePortalCommitmentEdge {
node {
id
icon
eyebrow
title
description
rank
}
}
}
}
`;
const updateCommitmentMutation = graphql`
mutation CompliancePageCommitmentDialogUpdateMutation(
$input: UpdateCompliancePortalCommitmentInput!
) {
updateCompliancePortalCommitment(input: $input) {
compliancePortalCommitment {
id
icon
eyebrow
title
description
rank
}
}
}
`;
const commitmentSchema = z.object({
icon: z.string().min(1, "Icon is required"),
eyebrow: z.string(),
title: z.string().min(1, "Title is required"),
description: z.string().min(1, "Description is required"),
});
type CommitmentFormData = z.infer<typeof commitmentSchema>;
export type CompliancePageCommitmentDialogRef = {
openCreate: (groupId: string) => void;
openEdit: (commitment: CompliancePageCommitmentListItemFragment$data) => void;
};
export const CompliancePageCommitmentDialog = forwardRef<
CompliancePageCommitmentDialogRef,
{ onChanged: () => void }
>(function CompliancePageCommitmentDialog({ onChanged }, ref) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [mode, setMode] = useState<"create" | "edit">("create");
const [groupId, setGroupId] = useState<string>("");
const [commitmentId, setCommitmentId] = useState<string>("");
const [createCommitment, isCreating] = useMutationWithToasts<CompliancePageCommitmentDialogCreateMutation>(
createCommitmentMutation,
{ successMessage: __("Commitment created successfully"), errorMessage: __("Failed to create commitment") },
);
const [updateCommitment, isUpdating] = useMutationWithToasts<CompliancePageCommitmentDialogUpdateMutation>(
updateCommitmentMutation,
{ successMessage: __("Commitment updated successfully"), errorMessage: __("Failed to update commitment") },
);
const { register, handleSubmit, control, formState: { errors }, reset } = useFormWithSchema(commitmentSchema, {
defaultValues: { icon: COMMITMENT_ICON_VALUES[0], eyebrow: "", title: "", description: "" },
});
useImperativeHandle(ref, () => ({
openCreate: (gId: string) => {
setMode("create");
setGroupId(gId);
reset({ icon: COMMITMENT_ICON_VALUES[0], eyebrow: "", title: "", description: "" });
dialogRef.current?.open();
},
openEdit: (commitment) => {
setMode("edit");
setCommitmentId(commitment.id);
reset({
icon: commitment.icon,
eyebrow: commitment.eyebrow,
title: commitment.title,
description: commitment.description,
});
dialogRef.current?.open();
},
}));
const onSubmit = async (data: CommitmentFormData) => {
const icon = data.icon as CompliancePortalCommitmentIcon;
if (mode === "create") {
await createCommitment({
variables: {
input: { groupId, icon, eyebrow: data.eyebrow, title: data.title, description: data.description },
},
onSuccess: () => {
reset();
dialogRef.current?.close();
onChanged();
},
});
} else {
await updateCommitment({
variables: {
input: { id: commitmentId, icon, eyebrow: data.eyebrow, title: data.title, description: data.description },
},
onSuccess: () => {
reset();
dialogRef.current?.close();
onChanged();
},
});
}
};
const isSubmitting = isCreating || isUpdating;
const title = mode === "create" ? __("Add Commitment") : __("Edit Commitment");
return (
<Dialog ref={dialogRef} title={title} className="max-w-2xl" onClose={() => reset()}>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-6">
<div className="space-y-1.5">
<Label>{__("Icon")}</Label>
<ControlledSelect control={control} name="icon" placeholder={__("Select an icon")}>
{COMMITMENT_ICON_VALUES.map(value => (
<Option key={value} value={value}>
{COMMITMENT_ICON_LABELS[value]}
</Option>
))}
</ControlledSelect>
</div>
<Field
{...register("eyebrow")}
label={__("Eyebrow")}
type="text"
error={errors.eyebrow?.message}
placeholder={__("Small accent label above the title")}
/>
<Field
{...register("title")}
label={__("Title")}
type="text"
required
error={errors.title?.message}
placeholder={__("Commitment headline")}
/>
<Field label={__("Description")} error={errors.description?.message} required>
<Textarea
{...register("description")}
placeholder={__("Supporting body copy")}
rows={3}
/>
</Field>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isSubmitting} icon={isSubmitting ? Spinner : undefined}>
{mode === "create" ? __("Add Commitment") : __("Update Commitment")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
});

View File

@@ -0,0 +1,168 @@
// 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 { useTranslate } from "@probo/i18n";
import { Button, Dialog, DialogContent, DialogFooter, Field, Spinner, Textarea, useDialogRef } from "@probo/ui";
import { forwardRef, useImperativeHandle, useState } from "react";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { CompliancePageCommitmentGroupDialogCreateMutation } from "#/__generated__/core/CompliancePageCommitmentGroupDialogCreateMutation.graphql";
import type { CompliancePageCommitmentGroupDialogUpdateMutation } from "#/__generated__/core/CompliancePageCommitmentGroupDialogUpdateMutation.graphql";
import type { CompliancePageCommitmentGroupListItemFragment$data } from "#/__generated__/core/CompliancePageCommitmentGroupListItemFragment.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const createGroupMutation = graphql`
mutation CompliancePageCommitmentGroupDialogCreateMutation(
$input: CreateCompliancePortalCommitmentGroupInput!
) {
createCompliancePortalCommitmentGroup(input: $input) {
compliancePortalCommitmentGroupEdge {
node {
id
title
description
rank
}
}
}
}
`;
const updateGroupMutation = graphql`
mutation CompliancePageCommitmentGroupDialogUpdateMutation(
$input: UpdateCompliancePortalCommitmentGroupInput!
) {
updateCompliancePortalCommitmentGroup(input: $input) {
compliancePortalCommitmentGroup {
id
title
description
rank
}
}
}
`;
const groupSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().min(1, "Description is required"),
});
type GroupFormData = z.infer<typeof groupSchema>;
export type CompliancePageCommitmentGroupDialogRef = {
openCreate: (trustCenterId: string) => void;
openEdit: (group: CompliancePageCommitmentGroupListItemFragment$data) => void;
};
export const CompliancePageCommitmentGroupDialog = forwardRef<
CompliancePageCommitmentGroupDialogRef,
{ onChanged: () => void }
>(function CompliancePageCommitmentGroupDialog({ onChanged }, ref) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [mode, setMode] = useState<"create" | "edit">("create");
const [trustCenterId, setTrustCenterId] = useState<string>("");
const [groupId, setGroupId] = useState<string>("");
const [createGroup, isCreating] = useMutationWithToasts<CompliancePageCommitmentGroupDialogCreateMutation>(
createGroupMutation,
{ successMessage: __("Group created successfully"), errorMessage: __("Failed to create group") },
);
const [updateGroup, isUpdating] = useMutationWithToasts<CompliancePageCommitmentGroupDialogUpdateMutation>(
updateGroupMutation,
{ successMessage: __("Group updated successfully"), errorMessage: __("Failed to update group") },
);
const { register, handleSubmit, formState: { errors }, reset } = useFormWithSchema(groupSchema, {
defaultValues: { title: "", description: "" },
});
useImperativeHandle(ref, () => ({
openCreate: (tId: string) => {
setMode("create");
setTrustCenterId(tId);
reset({ title: "", description: "" });
dialogRef.current?.open();
},
openEdit: (group) => {
setMode("edit");
setGroupId(group.id);
reset({ title: group.title, description: group.description });
dialogRef.current?.open();
},
}));
const onSubmit = async (data: GroupFormData) => {
if (mode === "create") {
await createGroup({
variables: { input: { trustCenterId, title: data.title, description: data.description } },
onSuccess: () => {
reset();
dialogRef.current?.close();
onChanged();
},
});
} else {
await updateGroup({
variables: { input: { id: groupId, title: data.title, description: data.description } },
onSuccess: () => {
reset();
dialogRef.current?.close();
onChanged();
},
});
}
};
const isSubmitting = isCreating || isUpdating;
const title = mode === "create" ? __("Add Group") : __("Edit Group");
return (
<Dialog ref={dialogRef} title={title} className="max-w-2xl" onClose={() => reset()}>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-6">
<Field
{...register("title")}
label={__("Title")}
type="text"
required
error={errors.title?.message}
placeholder={__("e.g. Data Protection")}
/>
<Field label={__("Description")} error={errors.description?.message} required>
<Textarea
{...register("description")}
placeholder={__("Describe what this group of commitments covers")}
rows={3}
/>
</Field>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isSubmitting} icon={isSubmitting ? Spinner : undefined}>
{mode === "create" ? __("Add Group") : __("Update Group")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
});

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Button, Card, IconPlusLarge } from "@probo/ui";
import { useRef } from "react";
import { useRefetchableFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageCommitmentGroupListFragment$key } from "#/__generated__/core/CompliancePageCommitmentGroupListFragment.graphql";
import type { CompliancePageCommitmentGroupListRefetchQuery } from "#/__generated__/core/CompliancePageCommitmentGroupListRefetchQuery.graphql";
import { CompliancePageCommitmentGroupDialog, type CompliancePageCommitmentGroupDialogRef } from "./CompliancePageCommitmentGroupDialog";
import { CompliancePageCommitmentGroupListItem } from "./CompliancePageCommitmentGroupListItem";
const fragment = graphql`
fragment CompliancePageCommitmentGroupListFragment on TrustCenter
@refetchable(queryName: "CompliancePageCommitmentGroupListRefetchQuery") {
commitmentGroups(first: 100, orderBy: { field: RANK, direction: ASC }) {
edges {
node {
id
...CompliancePageCommitmentGroupListItemFragment
}
}
}
}
`;
export function CompliancePageCommitmentGroupList(props: {
fragmentRef: CompliancePageCommitmentGroupListFragment$key;
trustCenterId: string;
canCreate: boolean;
}) {
const { fragmentRef, trustCenterId, canCreate } = props;
const { __ } = useTranslate();
const dialogRef = useRef<CompliancePageCommitmentGroupDialogRef>(null);
const [data, refetch] = useRefetchableFragment<
CompliancePageCommitmentGroupListRefetchQuery,
CompliancePageCommitmentGroupListFragment$key
>(fragment, fragmentRef);
const onChanged = () => refetch({}, { fetchPolicy: "network-only" });
const groups = data.commitmentGroups.edges.map(edge => edge.node);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-medium">{__("Security Commitments")}</h2>
<p className="text-sm text-txt-tertiary">
{__("Group commitment cards into sections shown on your compliance page")}
</p>
</div>
{canCreate && (
<Button icon={IconPlusLarge} onClick={() => dialogRef.current?.openCreate(trustCenterId)}>
{__("Add Group")}
</Button>
)}
</div>
{groups.length === 0
? (
<Card className="p-6 text-center text-sm text-txt-secondary">
{__("No commitment groups yet")}
</Card>
)
: (
<div className="space-y-6">
{groups.map(group => (
<CompliancePageCommitmentGroupListItem
key={group.id}
fragmentRef={group}
onEdit={g => dialogRef.current?.openEdit(g)}
onChanged={onChanged}
/>
))}
</div>
)}
<CompliancePageCommitmentGroupDialog ref={dialogRef} onChanged={onChanged} />
</div>
);
}

View File

@@ -0,0 +1,173 @@
// 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 { sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Button, Card, Dialog, DialogContent, DialogFooter, IconPencil, IconPlusLarge, IconTrashCan, Spinner, Table, Tbody, Td, Th, Thead, Tr, useDialogRef } from "@probo/ui";
import { useRef } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageCommitmentGroupListItemDeleteMutation } from "#/__generated__/core/CompliancePageCommitmentGroupListItemDeleteMutation.graphql";
import type { CompliancePageCommitmentGroupListItemFragment$data, CompliancePageCommitmentGroupListItemFragment$key } from "#/__generated__/core/CompliancePageCommitmentGroupListItemFragment.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CompliancePageCommitmentDialog, type CompliancePageCommitmentDialogRef } from "./CompliancePageCommitmentDialog";
import { CompliancePageCommitmentListItem } from "./CompliancePageCommitmentListItem";
const deleteGroupMutation = graphql`
mutation CompliancePageCommitmentGroupListItemDeleteMutation(
$input: DeleteCompliancePortalCommitmentGroupInput!
) {
deleteCompliancePortalCommitmentGroup(input: $input) {
deletedCompliancePortalCommitmentGroupId
}
}
`;
const fragment = graphql`
fragment CompliancePageCommitmentGroupListItemFragment on CompliancePortalCommitmentGroup {
id
title
description
canUpdate: permission(action: "core:compliance-portal-commitment-group:update")
canDelete: permission(action: "core:compliance-portal-commitment-group:delete")
canCreateCommitment: permission(action: "core:compliance-portal-commitment:create")
commitments(first: 100, orderBy: { field: RANK, direction: ASC }) {
edges {
node {
id
...CompliancePageCommitmentListItemFragment
}
}
}
}
`;
export function CompliancePageCommitmentGroupListItem(props: {
fragmentRef: CompliancePageCommitmentGroupListItemFragment$key;
onEdit: (group: CompliancePageCommitmentGroupListItemFragment$data) => void;
onChanged: () => void;
}) {
const { fragmentRef, onEdit, onChanged } = props;
const { __ } = useTranslate();
const group = useFragment<CompliancePageCommitmentGroupListItemFragment$key>(fragment, fragmentRef);
const commitmentDialogRef = useRef<CompliancePageCommitmentDialogRef>(null);
const deleteDialogRef = useDialogRef();
const [deleteGroup, isDeleting] = useMutationWithToasts<CompliancePageCommitmentGroupListItemDeleteMutation>(
deleteGroupMutation,
{ successMessage: __("Group deleted successfully"), errorMessage: __("Failed to delete group") },
);
const commitments = group.commitments.edges.map(edge => edge.node);
const handleDelete = async () => {
await deleteGroup({
variables: { input: { id: group.id } },
onSuccess: () => {
deleteDialogRef.current?.close();
onChanged();
},
});
};
return (
<Card className="space-y-4 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-medium">{group.title}</h3>
<p className="text-sm text-txt-tertiary">{group.description}</p>
</div>
<div className="flex gap-2">
{group.canUpdate && (
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(group)} />
)}
{group.canDelete && (
<>
<Button variant="danger" icon={IconTrashCan} onClick={() => deleteDialogRef.current?.open()} />
<Dialog ref={deleteDialogRef} title={__("Delete Group")} className="max-w-md">
<DialogContent padded>
<p className="text-txt-secondary">
{sprintf(__("Are you sure you want to delete the group \"%s\"?"), group.title)}
</p>
<p className="text-txt-secondary mt-2">
{__("All commitment cards in this group will also be deleted. This action cannot be undone.")}
</p>
</DialogContent>
<DialogFooter>
<Button
variant="danger"
onClick={() => void handleDelete()}
disabled={isDeleting}
icon={isDeleting ? Spinner : IconTrashCan}
>
{isDeleting ? __("Deleting...") : __("Delete")}
</Button>
</DialogFooter>
</Dialog>
</>
)}
</div>
</div>
<Table>
<Thead>
<Tr>
<Th>{__("Icon")}</Th>
<Th>{__("Title")}</Th>
<Th>{__("Description")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{commitments.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
{__("No commitment cards yet")}
</Td>
</Tr>
)}
{commitments.map(commitment => (
<CompliancePageCommitmentListItem
key={commitment.id}
fragmentRef={commitment}
onEdit={c => commitmentDialogRef.current?.openEdit(c)}
onChanged={onChanged}
/>
))}
</Tbody>
</Table>
{group.canCreateCommitment && (
<Button
variant="secondary"
icon={IconPlusLarge}
onClick={() => commitmentDialogRef.current?.openCreate(group.id)}
>
{__("Add Commitment")}
</Button>
)}
<CompliancePageCommitmentDialog ref={commitmentDialogRef} onChanged={onChanged} />
</Card>
);
}

View File

@@ -0,0 +1,111 @@
// 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 { useTranslate } from "@probo/i18n";
import { Badge, Button, IconPencil, IconTrashCan, Spinner, Td, Tr } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageCommitmentListItemDeleteMutation } from "#/__generated__/core/CompliancePageCommitmentListItemDeleteMutation.graphql";
import type { CompliancePageCommitmentListItemFragment$data, CompliancePageCommitmentListItemFragment$key } from "#/__generated__/core/CompliancePageCommitmentListItemFragment.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { COMMITMENT_ICON_LABELS } from "../_lib/commitmentIcons";
const deleteCommitmentMutation = graphql`
mutation CompliancePageCommitmentListItemDeleteMutation(
$input: DeleteCompliancePortalCommitmentInput!
) {
deleteCompliancePortalCommitment(input: $input) {
deletedCompliancePortalCommitmentId
}
}
`;
const fragment = graphql`
fragment CompliancePageCommitmentListItemFragment on CompliancePortalCommitment {
id
icon
eyebrow
title
description
canUpdate: permission(action: "core:compliance-portal-commitment:update")
canDelete: permission(action: "core:compliance-portal-commitment:delete")
}
`;
export function CompliancePageCommitmentListItem(props: {
fragmentRef: CompliancePageCommitmentListItemFragment$key;
onEdit: (commitment: CompliancePageCommitmentListItemFragment$data) => void;
onChanged: () => void;
}) {
const { fragmentRef, onEdit, onChanged } = props;
const { __ } = useTranslate();
const commitment = useFragment<CompliancePageCommitmentListItemFragment$key>(fragment, fragmentRef);
const [deleteCommitment, isDeleting] = useMutationWithToasts<CompliancePageCommitmentListItemDeleteMutation>(
deleteCommitmentMutation,
{ successMessage: __("Commitment deleted successfully"), errorMessage: __("Failed to delete commitment") },
);
const handleDelete = async () => {
await deleteCommitment({
variables: { input: { id: commitment.id } },
onSuccess: onChanged,
});
};
const iconLabel = COMMITMENT_ICON_LABELS[commitment.icon];
return (
<Tr>
<Td>
<Badge variant="neutral">{iconLabel}</Badge>
</Td>
<Td>
<div className="flex flex-col">
{commitment.eyebrow && (
<span className="text-xs text-txt-tertiary">{commitment.eyebrow}</span>
)}
<span className="font-medium">{commitment.title}</span>
</div>
</Td>
<Td>
<span className="text-txt-secondary line-clamp-2">{commitment.description}</span>
</Td>
<Td noLink width={120} className="text-end">
<div className="flex gap-2 justify-end">
{commitment.canUpdate && (
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(commitment)} />
)}
{commitment.canDelete && (
<Button
variant="danger"
icon={isDeleting ? Spinner : IconTrashCan}
disabled={isDeleting}
onClick={() => void handleDelete()}
/>
)}
</div>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,70 @@
// 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 { CompliancePortalCommitmentIcon } from "#/__generated__/core/CompliancePageCommitmentDialogCreateMutation.graphql";
// The curated icon set mirrors coredata.CompliancePortalCommitmentIcon and the
// Phosphor icons the compliance portal renders for each value.
export const COMMITMENT_ICON_VALUES: CompliancePortalCommitmentIcon[] = [
"LOCK_KEY",
"EYE_SLASH",
"FINGERPRINT",
"SHIELD_WARNING",
"SHIELD_CHECK",
"SIREN",
"KEY",
"LOCK",
"CLOUD",
"DATABASE",
"GLOBE",
"EYE",
"USERS",
"CERTIFICATE",
"GAVEL",
"HEARTBEAT",
"BELL",
"BUG",
"CODE",
"SERVER",
];
// Human-readable label for each icon value, shown in the console picker.
export const COMMITMENT_ICON_LABELS: Record<CompliancePortalCommitmentIcon, string> = {
LOCK_KEY: "Lock & key",
EYE_SLASH: "Eye slash",
FINGERPRINT: "Fingerprint",
SHIELD_WARNING: "Shield warning",
SHIELD_CHECK: "Shield check",
SIREN: "Siren",
KEY: "Key",
LOCK: "Lock",
CLOUD: "Cloud",
DATABASE: "Database",
GLOBE: "Globe",
EYE: "Eye",
USERS: "Users",
CERTIFICATE: "Certificate",
GAVEL: "Gavel",
HEARTBEAT: "Heartbeat",
BELL: "Bell",
BUG: "Bug",
CODE: "Code",
SERVER: "Server",
};

View File

@@ -52,6 +52,11 @@ export const compliancePageRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/compliance-page/references/CompliancePageReferencesPageLoader")),
},
{
path: "commitments",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/compliance-page/commitments/CompliancePageCommitmentsPageLoader")),
},
{
path: "audits",
Fallback: LinkCardSkeleton,