Add compliance frameworks

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-03 08:56:39 +01:00
parent 4d882e37b0
commit c17c53e80f
36 changed files with 5303 additions and 90 deletions

View File

@@ -1,8 +1,10 @@
import { useTranslate } from "@probo/i18n";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageOverviewPageQuery } from "#/__generated__/core/CompliancePageOverviewPageQuery.graphql";
import { CompliancePageFrameworkList } from "./_components/CompliancePageFrameworkList";
import { CompliancePageNDASection } from "./_components/CompliancePageNDASection";
import { CompliancePageSlackSection } from "./_components/CompliancePageSlackSection";
import { CompliancePageStatusSection } from "./_components/CompliancePageStatusSection";
@@ -13,6 +15,7 @@ export const compliancePageOverviewPageQuery = graphql`
... on Organization {
compliancePage: trustCenter {
canGetNDA: permission(action: "core:trust-center:get-nda")
...CompliancePageFrameworkList_compliancePageFragment
}
}
...CompliancePageStatusSectionFragment
@@ -25,6 +28,8 @@ export const compliancePageOverviewPageQuery = graphql`
export function CompliancePageOverviewPage(props: { queryRef: PreloadedQuery<CompliancePageOverviewPageQuery> }) {
const { queryRef } = props;
const { __ } = useTranslate();
const { organization } = usePreloadedQuery<CompliancePageOverviewPageQuery>(
compliancePageOverviewPageQuery,
queryRef,
@@ -39,6 +44,18 @@ export function CompliancePageOverviewPage(props: { queryRef: PreloadedQuery<Com
)}
<CompliancePageSlackSection fragmentRef={organization} />
{organization.compliancePage && (
<div className="space-y-4">
<div>
<h3 className="text-base font-medium">{__("Frameworks")}</h3>
<p className="text-sm text-txt-tertiary">
{__("Select which frameworks to display as badges on your compliance page")}
</p>
</div>
<CompliancePageFrameworkList compliancePageRef={organization.compliancePage} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,358 @@
import { useTranslate } from "@probo/i18n";
import { Badge, Field, FrameworkLogo, Option, Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import { useCallback, useState, useTransition } from "react";
import { useRefetchableFragment } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
import type { CompliancePageFrameworkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql";
import type { CompliancePageFrameworkList_compliancePageFragment$data } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql";
import type { CompliancePageFrameworkList_compliancePageRefetchQuery } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageRefetchQuery.graphql";
import type { CompliancePageFrameworkList_createMutation } from "#/__generated__/core/CompliancePageFrameworkList_createMutation.graphql";
import type { CompliancePageFrameworkList_deleteMutation } from "#/__generated__/core/CompliancePageFrameworkList_deleteMutation.graphql";
import type { CompliancePageFrameworkList_updateRankMutation } from "#/__generated__/core/CompliancePageFrameworkList_updateRankMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const compliancePageFragment = graphql`
fragment CompliancePageFrameworkList_compliancePageFragment on TrustCenter
@refetchable(queryName: "CompliancePageFrameworkList_compliancePageRefetchQuery")
@argumentDefinitions(
first: { type: Int, defaultValue: 100 }
after: { type: CursorKey, defaultValue: null }
order: { type: ComplianceFrameworkOrder, defaultValue: { field: RANK, direction: ASC } }
) {
id
canUpdate: permission(action: "core:trust-center:update")
complianceFrameworks(first: $first, after: $after, orderBy: $order)
@connection(key: "CompliancePageFrameworkList_complianceFrameworks", filters: ["orderBy"]) {
__id
edges {
node {
id
rank
visibility
framework {
id
name
lightLogoURL
darkLogoURL
}
}
}
}
}
`;
const createMutation = graphql`
mutation CompliancePageFrameworkList_createMutation($input: CreateComplianceFrameworkInput!) {
createComplianceFramework(input: $input) {
complianceFrameworkEdge {
node {
id
}
}
}
}
`;
const updateRankMutation = graphql`
mutation CompliancePageFrameworkList_updateRankMutation($input: UpdateComplianceFrameworkInput!) {
updateComplianceFramework(input: $input) {
complianceFramework {
id
rank
}
}
}
`;
const deleteMutation = graphql`
mutation CompliancePageFrameworkList_deleteMutation($input: DeleteComplianceFrameworkInput!) {
deleteComplianceFramework(input: $input) {
deletedComplianceFrameworkId
}
}
`;
type Edge = CompliancePageFrameworkList_compliancePageFragment$data["complianceFrameworks"]["edges"][number];
function CompliancePageFrameworkListItem(props: {
edge: Edge;
compliancePage: CompliancePageFrameworkList_compliancePageFragment$data;
draggedCfId: string | null;
dragOverCfId: string | null;
onDragStart: (cfId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent, cfId: string) => void;
onDrop: (cfId: string) => void;
onRefetch: () => void;
}) {
const { edge, draggedCfId, dragOverCfId, onDragStart, onDragEnd, onDragOver, onDrop, onRefetch } = props;
const { __ } = useTranslate();
const [isMouseDown, setIsMouseDown] = useState(false);
const compliancePage = props.compliancePage;
const { id, visibility, framework } = edge.node;
const isPublic = visibility === "PUBLIC";
const canDrag = isPublic && compliancePage.canUpdate;
const isDragging = draggedCfId === id;
const isDropTarget = dragOverCfId === id && draggedCfId !== id;
const [createComplianceFramework, isCreating] = useMutationWithToasts<CompliancePageFrameworkList_createMutation>(
createMutation,
{
successMessage: __("Framework visibility updated successfully."),
errorMessage: __("Failed to update framework visibility"),
},
);
const [deleteComplianceFramework, isDeleting] = useMutationWithToasts<CompliancePageFrameworkList_deleteMutation>(
deleteMutation,
{
successMessage: __("Framework visibility updated successfully."),
errorMessage: __("Failed to update framework visibility"),
},
);
const isLoading = isCreating || isDeleting;
const handleVisibilityChange = useCallback(
async (value: string) => {
if (value === "PUBLIC" && !isPublic) {
await createComplianceFramework({
variables: {
input: {
trustCenterId: compliancePage.id,
frameworkId: framework.id,
},
},
onCompleted: (_, errors) => {
if (!errors?.length) {
onRefetch();
}
},
});
} else if (value === "NONE" && isPublic) {
await deleteComplianceFramework({
variables: {
input: { id },
},
onCompleted: (_, errors) => {
if (!errors?.length) {
onRefetch();
}
},
});
}
},
[compliancePage.id, framework.id, id, isPublic, onRefetch, createComplianceFramework, deleteComplianceFramework],
);
const visibilityOptions = [
{ value: "PUBLIC", label: __("Public"), variant: "success" as const },
{ value: "NONE", label: __("None"), variant: "neutral" as const },
];
const rowClassName = [
canDrag && isDragging && "opacity-50 cursor-grabbing",
canDrag && !isDragging && !isMouseDown && "cursor-grab",
canDrag && !isDragging && isMouseDown && "cursor-grabbing",
isDropTarget && "!bg-primary-50 border-y-2 border-primary-500",
]
.filter(Boolean)
.join(" ");
return (
<Tr
draggable={canDrag}
onDragStart={canDrag ? () => onDragStart(id) : undefined}
onDragEnd={canDrag ? onDragEnd : undefined}
onDragOver={canDrag ? e => onDragOver(e, id) : undefined}
onDrop={
canDrag
? (e) => {
e.preventDefault();
onDrop(id);
}
: undefined
}
onMouseDown={canDrag ? () => setIsMouseDown(true) : undefined}
onMouseUp={canDrag ? () => setIsMouseDown(false) : undefined}
onMouseLeave={canDrag ? () => setIsMouseDown(false) : undefined}
className={rowClassName}
>
<Td>
<div className="flex items-center gap-3">
<FrameworkLogo
className="size-8"
lightLogoURL={framework.lightLogoURL}
darkLogoURL={framework.darkLogoURL}
name={framework.name}
/>
{framework.name}
</div>
</Td>
<Td noLink width={130} className="pr-0">
<Field
type="select"
value={visibility}
onValueChange={value => void handleVisibilityChange(value)}
disabled={isLoading || !compliancePage.canUpdate}
className="w-[105px]"
>
{visibilityOptions.map(option => (
<Option key={option.value} value={option.value}>
<div className="flex items-center justify-between w-full">
<Badge variant={option.variant}>{option.label}</Badge>
</div>
</Option>
))}
</Field>
</Td>
</Tr>
);
}
export function CompliancePageFrameworkList(props: {
compliancePageRef: CompliancePageFrameworkList_compliancePageFragment$key;
}) {
const { __ } = useTranslate();
const [, startTransition] = useTransition();
const [compliancePage, refetch] = useRefetchableFragment<
CompliancePageFrameworkList_compliancePageRefetchQuery,
CompliancePageFrameworkList_compliancePageFragment$key
>(compliancePageFragment, props.compliancePageRef);
const [updateRank] = useMutationWithToasts<CompliancePageFrameworkList_updateRankMutation>(
updateRankMutation,
{
successMessage: __("Order updated successfully"),
errorMessage: __("Failed to update order"),
},
);
const [draggedCfId, setDraggedCfId] = useState<string | null>(null);
const [dragOverCfId, setDragOverCfId] = useState<string | null>(null);
const allEdges = compliancePage.complianceFrameworks.edges;
const publicEdges = allEdges.filter(e => e.node.visibility === "PUBLIC");
const handleDragStart = (cfId: string) => {
setDraggedCfId(cfId);
};
const handleDragEnd = () => {
setDraggedCfId(null);
setDragOverCfId(null);
};
const handleDragOver = (e: React.DragEvent, cfId: string) => {
e.preventDefault();
if (draggedCfId !== cfId) {
setDragOverCfId(cfId);
}
};
const handleDrop = async (targetCfId: string) => {
if (!draggedCfId || draggedCfId === targetCfId) {
setDraggedCfId(null);
setDragOverCfId(null);
return;
}
const targetEdge = publicEdges.find(e => e.node.id === targetCfId);
if (!targetEdge) {
setDraggedCfId(null);
setDragOverCfId(null);
return;
}
const draggedId = draggedCfId;
await updateRank({
variables: {
input: {
id: draggedId,
rank: targetEdge.node.rank,
},
},
updater: (store) => {
const trustCenterRecord = store.get(compliancePage.id);
if (!trustCenterRecord) return;
const connection = ConnectionHandler.getConnection(
trustCenterRecord,
"CompliancePageFrameworkList_complianceFrameworks",
{ orderBy: { field: "RANK", direction: "ASC" } },
);
if (!connection) return;
const edges = connection.getLinkedRecords("edges");
if (!edges) return;
const fromIdx = edges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === draggedId);
const toIdx = edges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === targetCfId);
if (fromIdx === -1 || toIdx === -1) return;
const reordered = [...edges];
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
connection.setLinkedRecords(reordered, "edges");
},
onCompleted: (_, errors) => {
startTransition(() => {
refetch({}, { fetchPolicy: errors?.length ? "network-only" : "store-and-network" });
});
},
});
setDraggedCfId(null);
setDragOverCfId(null);
};
const hasMultiplePublic = publicEdges.length > 1;
return (
<>
<Table>
<Thead>
<Tr>
<Th>{__("Framework")}</Th>
<Th>{__("Visibility")}</Th>
</Tr>
</Thead>
<Tbody>
{allEdges.length === 0 && (
<Tr>
<Td colSpan={2} className="text-center text-txt-secondary">
{__("No frameworks available")}
</Td>
</Tr>
)}
{allEdges.map(edge => (
<CompliancePageFrameworkListItem
key={edge.node.id}
edge={edge}
compliancePage={compliancePage}
draggedCfId={draggedCfId}
dragOverCfId={dragOverCfId}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={cfId => void handleDrop(cfId)}
onRefetch={() => startTransition(() => { refetch({}, { fetchPolicy: "store-and-network" }); })}
/>
))}
</Tbody>
</Table>
{hasMultiplePublic && compliancePage.canUpdate && (
<p className="text-sm text-txt-tertiary">
{__("Drag and drop public frameworks to change their displayed order")}
</p>
)}
</>
);
}

View File

@@ -126,7 +126,7 @@ export function CompliancePageReferenceList(props: {
</Tbody>
</Table>
<p className="text-xs text-txt-tertiary">
<p className="text-sm text-txt-tertiary">
{__("Drag and drop references to change their displayed order")}
</p>
</>