Add async third-party vetting
Queue vetting on third_parties with PENDING, PROCESSING, COMPLETED, and FAILED states. Expose enqueue and status through GraphQL, MCP, CLI, and n8n, validate vet requests, tune the worker via config, and poll the detail page while vetting runs. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -26,13 +26,16 @@ import {
|
||||
TabLink,
|
||||
Tabs,
|
||||
} from "@probo/ui";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
ConnectionHandler,
|
||||
type PreloadedQuery,
|
||||
useFragment,
|
||||
usePreloadedQuery,
|
||||
useRelayEnvironment,
|
||||
} from "react-relay";
|
||||
import { Outlet } from "react-router";
|
||||
import { fetchQuery } from "relay-runtime";
|
||||
|
||||
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
|
||||
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
@@ -43,7 +46,7 @@ import {
|
||||
} from "#/hooks/graph/ThirdPartyGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
|
||||
import { VettingDialog } from "./dialogs/VettingDialog";
|
||||
import { measuresFragment } from "./measures/ThirdPartyMeasuresPage";
|
||||
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
|
||||
|
||||
@@ -54,9 +57,34 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function ThirdPartyDetailPage(props: Props) {
|
||||
const environment = useRelayEnvironment();
|
||||
const { node: thirdParty } = usePreloadedQuery(thirdPartyNodeQuery, props.queryRef);
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const thirdPartyIdRef = useRef(thirdParty.id);
|
||||
|
||||
useEffect(() => {
|
||||
thirdPartyIdRef.current = thirdParty.id;
|
||||
}, [thirdParty.id]);
|
||||
|
||||
const isVetting = thirdParty.vettingStatus === "PENDING" || thirdParty.vettingStatus === "PROCESSING";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVetting) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
|
||||
fetchQuery<ThirdPartyGraphNodeQuery>(
|
||||
environment,
|
||||
thirdPartyNodeQuery,
|
||||
{ thirdPartyId: thirdPartyIdRef.current },
|
||||
{ fetchPolicy: "network-only" },
|
||||
).subscribe({});
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isVetting, environment]);
|
||||
|
||||
const deleteThirdParty = useDeleteThirdParty(
|
||||
thirdParty,
|
||||
@@ -74,8 +102,24 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
const baseThirdPartyUrl
|
||||
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
|
||||
|
||||
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isVetting && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-warning px-4 py-3 text-sm text-txt-warning">
|
||||
<div
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 animate-spin rounded-full border-2 border-border-warning/30 border-t-border-warning"
|
||||
/>
|
||||
{__("Vetting is in progress. Results will appear once the analysis is complete.")}
|
||||
</div>
|
||||
)}
|
||||
{isVettingFailed && (
|
||||
<div className="rounded-lg bg-danger px-4 py-3 text-sm text-txt-danger">
|
||||
{__("Vetting failed. You can start vetting again.")}
|
||||
</div>
|
||||
)}
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
@@ -104,12 +148,15 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{thirdParty.canAssess && (
|
||||
<ImportAssessmentDialog thirdPartyId={thirdParty.id}>
|
||||
{thirdParty.canVet && !isVetting && (
|
||||
<VettingDialog
|
||||
thirdPartyId={thirdParty.id}
|
||||
websiteUrl={thirdParty.websiteUrl}
|
||||
>
|
||||
<Button icon={IconPageTextLine} variant="secondary">
|
||||
{__("Assessment From Website")}
|
||||
{__("Start Vetting")}
|
||||
</Button>
|
||||
</ImportAssessmentDialog>
|
||||
</VettingDialog>
|
||||
)}
|
||||
{thirdParty.canDelete && (
|
||||
<ActionDropdown variant="secondary">
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
@@ -20,25 +21,28 @@ import {
|
||||
DialogFooter,
|
||||
Field,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { VettingDialogMutation } from "#/__generated__/core/VettingDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const schema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
|
||||
const importAssessmentMutation = graphql`
|
||||
mutation ImportAssessmentDialogMutation($input: AssessThirdPartyInput!) {
|
||||
assessThirdParty(input: $input) {
|
||||
const vetMutation = graphql`
|
||||
mutation VettingDialogMutation($input: VetThirdPartyInput!) {
|
||||
vetThirdParty(input: $input) {
|
||||
thirdParty {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
vettingStatus
|
||||
...useThirdPartyFormFragment
|
||||
...ThirdPartyComplianceTabFragment
|
||||
...ThirdPartyRiskAssessmentTabFragment
|
||||
@@ -47,42 +51,64 @@ const importAssessmentMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
interface VettingDialogProps {
|
||||
thirdPartyId: string;
|
||||
websiteUrl?: string | null;
|
||||
children: ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
|
||||
export function VettingDialog({ thirdPartyId, websiteUrl, children }: VettingDialogProps) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const dialogRef = useDialogRef();
|
||||
const { register, handleSubmit, reset, formState } = useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
url: "",
|
||||
url: websiteUrl ?? "",
|
||||
},
|
||||
},
|
||||
);
|
||||
const [assess, isAssessing] = useMutationWithToasts(
|
||||
importAssessmentMutation,
|
||||
{
|
||||
successMessage: __("Third party assessed successfully."),
|
||||
errorMessage: __("Failed to assess third party"),
|
||||
},
|
||||
);
|
||||
const [vet, isVetting] = useMutation<VettingDialogMutation>(vetMutation);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
await assess({
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
vet({
|
||||
variables: {
|
||||
input: {
|
||||
id: thirdPartyId,
|
||||
websiteUrl: data.url,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to start vetting."),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("The third party is being vetted in the background."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to start vetting."),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -90,22 +116,22 @@ export function ImportAssessmentDialog({ thirdPartyId, children }: Props) {
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={__("Assessment from website")}
|
||||
title={__("Start Vetting")}
|
||||
className="max-w-lg"
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded>
|
||||
<Field
|
||||
required
|
||||
label={__("URL")}
|
||||
label={__("Website URL")}
|
||||
type="text"
|
||||
{...register("url")}
|
||||
error={formState.errors.url?.message}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isAssessing}>
|
||||
{__("Assess")}
|
||||
<Button type="submit" disabled={isVetting}>
|
||||
{__("Start Vetting")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -214,7 +214,9 @@ function AssessmentRow(props: AssessmentRowProps) {
|
||||
{__("Notes")}
|
||||
:
|
||||
</div>
|
||||
<p className="text-txt-secondary">{assessment.notes}</p>
|
||||
<p className="text-sm text-txt-secondary whitespace-pre-wrap">
|
||||
{assessment.notes}
|
||||
</p>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
Reference in New Issue
Block a user