157 lines
7.8 KiB
TypeScript
157 lines
7.8 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { BackendStatus } from '../../app/app-state';
|
|
import type { FindingRecord } from '../../domain/finding';
|
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
|
import { useEnginePreferences } from '../../app/engine-preferences';
|
|
import { useOptionalApp } from '../../app/AppProvider';
|
|
import { mappingRunKey, subscribeRegulatoryMappingRuns, trackRegulatoryMappingRun } from './mapping-run-tracker';
|
|
import { markRegulatoryDecisionSaved } from './decision-revision';
|
|
import {
|
|
acceptedControlIds,
|
|
type RegulatoryMappingCandidate,
|
|
type RegulatoryMappingDecisionValue,
|
|
type RegulatoryMappingResponse
|
|
} from '../regulatory-mapping';
|
|
|
|
/** An accepted control as the Finding Reader names it. */
|
|
export interface AcceptedRegulatoryControl {
|
|
controlId: string;
|
|
annexAControlId: string;
|
|
title?: string;
|
|
}
|
|
|
|
const assessorApi = new AssessorApiClient();
|
|
|
|
export function useRegulatoryMapping(
|
|
projectId: string,
|
|
/** Result DB row of the record: a scanner finding occurrence or a GPO baseline comparison. */
|
|
resultProjectionId: string,
|
|
backendStatus: BackendStatus
|
|
) {
|
|
// A switched-off Compliance Engine shows no control, opens nothing and sends no request.
|
|
const available = useEnginePreferences().compliance;
|
|
// The shown timeslot is where a single mapping comes from; the mapping history names it relative to its view.
|
|
const app = useOptionalApp();
|
|
const origin = useRef({ assessmentIds: [] as string[], scanRunIds: [] as string[] });
|
|
origin.current = { assessmentIds: [...(app?.activeAssessmentIds ?? [])], scanRunIds: [...(app?.activeScanRunIds ?? [])] };
|
|
const [requestedOpen, setOpen] = useState(false);
|
|
const open = requestedOpen && available;
|
|
// Re-enabling the engine starts with the window closed again.
|
|
useEffect(() => { if (!available) setOpen(false); }, [available]);
|
|
const [storedResponse, setStoredResponse] = useState<{ scope: { key: string }; value: RegulatoryMappingResponse }>();
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const scopeKey = `${projectId}\u0000${resultProjectionId}\u0000${backendStatus}`;
|
|
const activeScope = useRef({ key: scopeKey });
|
|
if (activeScope.current.key !== scopeKey) activeScope.current = { key: scopeKey };
|
|
const requestScope = activeScope.current;
|
|
const response = storedResponse?.scope === requestScope ? storedResponse.value : undefined;
|
|
|
|
useEffect(() => {
|
|
setOpen(false);
|
|
setStoredResponse(undefined);
|
|
setLoading(false);
|
|
setError('');
|
|
}, [scopeKey]);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!projectId || !resultProjectionId || backendStatus !== 'connected' || activeScope.current !== requestScope) return;
|
|
setLoading(true);
|
|
try {
|
|
const next = await assessorApi.getRegulatoryMappings(projectId, resultProjectionId);
|
|
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
|
|
if (activeScope.current !== requestScope) return;
|
|
setStoredResponse({ scope: requestScope, value: next });
|
|
setError('');
|
|
} catch (reason) {
|
|
if (activeScope.current !== requestScope) return;
|
|
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be loaded.');
|
|
} finally {
|
|
if (activeScope.current === requestScope) setLoading(false);
|
|
}
|
|
}, [backendStatus, projectId, requestScope, resultProjectionId]);
|
|
|
|
// Loaded with the finding, so the reader can name its accepted controls. A running mapping is polled
|
|
// once by the run tracker, which keeps its activity entry until the backend ends it.
|
|
useEffect(() => { if (available && !response && !loading && !error) void load(); }, [available, error, load, loading, response]);
|
|
useEffect(() => subscribeRegulatoryMappingRuns((key, next) => {
|
|
if (key === mappingRunKey(projectId, resultProjectionId) && activeScope.current === requestScope) setStoredResponse({ scope: requestScope, value: next });
|
|
}), [projectId, requestScope, resultProjectionId]);
|
|
|
|
const start = useCallback(async () => {
|
|
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
|
|
setLoading(true);
|
|
try {
|
|
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId, origin: origin.current });
|
|
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
|
|
if (activeScope.current !== requestScope) return;
|
|
setStoredResponse({ scope: requestScope, value: next });
|
|
setError('');
|
|
} catch (reason) {
|
|
if (activeScope.current !== requestScope) return;
|
|
setError(reason instanceof Error ? reason.message : 'Regulatory mapping could not be started.');
|
|
} finally {
|
|
if (activeScope.current === requestScope) setLoading(false);
|
|
}
|
|
}, [projectId, requestScope, resultProjectionId]);
|
|
|
|
const decide = useCallback(async (candidate: Pick<RegulatoryMappingCandidate, 'controlId'>, decision: RegulatoryMappingDecisionValue, comment?: string) => {
|
|
if (!projectId || !resultProjectionId || !response?.run || activeScope.current !== requestScope) return;
|
|
const latest = response.decisions.find(item => item.controlId === candidate.controlId);
|
|
setLoading(true);
|
|
try {
|
|
const next = await assessorApi.saveRegulatoryMappingDecision(projectId, {
|
|
resultProjectionId,
|
|
controlId: candidate.controlId,
|
|
decision,
|
|
...(comment?.trim() ? { comment: comment.trim() } : {}),
|
|
...(latest ? { expectedLatestDecisionId: latest.id } : {})
|
|
});
|
|
markRegulatoryDecisionSaved();
|
|
if (activeScope.current !== requestScope) return;
|
|
setStoredResponse({ scope: requestScope, value: next });
|
|
setError('');
|
|
} catch (reason) {
|
|
if (activeScope.current !== requestScope) return;
|
|
setError(reason instanceof Error ? reason.message : 'The mapping decision could not be saved.');
|
|
} finally {
|
|
if (activeScope.current === requestScope) setLoading(false);
|
|
}
|
|
}, [projectId, requestScope, response, resultProjectionId]);
|
|
|
|
const acceptedControls = useMemo<AcceptedRegulatoryControl[]>(() => available && response
|
|
? acceptedControlIds(response.decisions).map(controlId => {
|
|
// A control decided in an earlier run keeps its title after the current run stopped retrieving it.
|
|
const candidate = response.run?.candidates.find(item => item.controlId === controlId)
|
|
?? response.earlierControls?.find(item => item.controlId === controlId);
|
|
return { controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, ...(candidate?.title ? { title: candidate.title } : {}) };
|
|
}).sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }))
|
|
: [], [available, response]);
|
|
|
|
const localDisabledReason = !resultProjectionId ? 'This record has no stored finding or current GPO baseline comparison.'
|
|
: backendStatus !== 'connected' ? 'The backend is not connected.'
|
|
: response && !response.generationEnabled ? response.disabledReason ?? 'Regulatory mapping is unavailable.' : undefined;
|
|
|
|
return {
|
|
available,
|
|
open,
|
|
setOpen,
|
|
response,
|
|
loading,
|
|
error,
|
|
start,
|
|
decide,
|
|
acceptedControls,
|
|
acceptedCount: acceptedControls.length,
|
|
/** BSI requirements derived from the accepted controls; undefined while the engine is off or none is derived. */
|
|
bsi: available ? response?.bsi : undefined,
|
|
disabledReason: localDisabledReason
|
|
};
|
|
}
|
|
|
|
/** Names the stored Result DB row an ISO mapping is keyed from; empty when the record has none. */
|
|
export function regulatoryMappingResultId(record: Pick<FindingRecord, 'findingOccurrenceId' | 'policyComparisonId'>): string {
|
|
if (record.findingOccurrenceId) return `scanner-finding:${record.findingOccurrenceId}`;
|
|
return record.policyComparisonId ? `policy-evaluation-comparison:${record.policyComparisonId}` : '';
|
|
}
|