Assessor_2.0/src/compliance/ui/ClientIsoControls.tsx

342 lines
22 KiB
TypeScript

import { useEffect, useId, useState, type ReactNode } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { BackendStatus } from '../../app/app-state';
import { useEnginePreferences } from '../../app/engine-preferences';
import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts';
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } from './decision-revision';
import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview';
import type { ComplianceMappingState } from './compliance-work-facets';
import { MappingReviewTile } from './MappingReviewTile';
import './compliance.css';
const assessorApi = new AssessorApiClient();
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
export const ISO_TILE_TITLE = 'ISO 27001/27002';
export const BSI_TILE_TITLE = 'BSI Grundschutz++';
interface Props {
projectId?: string;
assessmentIds?: readonly string[];
scanRunIds?: readonly string[];
backendStatus: BackendStatus;
/** Opens Work filtered to this accepted control, like a Context heatmap cell. */
onOpenControl(controlId: string): void;
/** Opens Work filtered to this ISO theme. */
onOpenTheme(theme: RegulatoryControlTheme): void;
/** Opens Work filtered to this derived BSI requirement. */
onOpenBsiRequirement(requirementId: string): void;
/** Opens Work filtered to this BSI practice. */
onOpenBsiPractice(practiceId: string): void;
/** Opens Work with the deviations in this `ISO mapping` facet state. */
onOpenMappingState(state: ComplianceMappingState): void;
/** The Client Info context row (client and timeslot), shown below the tab bar like on the Overview. */
context?: ReactNode;
/** The Client Info tab bar, shown above this tab like on the AI Overview. */
navigation?: ReactNode;
}
type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse };
const number = (value: number) => value.toLocaleString('en-US');
const count = (value: number, noun: string) => `${number(value)} ${noun}${value === 1 ? '' : 's'}`;
const byFindings = (left: RegulatoryControlOverviewItem, right: RegulatoryControlOverviewItem) =>
right.findings.length - left.findings.length || left.controlId.localeCompare(right.controlId, 'en', { numeric: true });
interface ThemeGroup { theme?: RegulatoryControlTheme; controls: RegulatoryControlOverviewItem[]; findings: number }
/** Themes in ISO 27002 order, only with hits; a control without a known theme follows without a drilldown. */
function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] {
return [...THEMES, undefined]
.map(theme => {
const themeControls = controls.filter(control => control.theme === theme).sort(byFindings);
// A finding with several controls of one theme counts once for that theme.
const findings = new Set(themeControls.flatMap(control => control.findings.map(finding => finding.resultProjectionId))).size;
return { theme, controls: themeControls, findings };
})
.filter(group => group.controls.length > 0);
}
/**
* Client Info `Compliance` tab: an overview row (mapping coverage and Annex A footprint), one tile per framework
* in the environment tile geometry, the complete control list in its Results window, and when the timeslot was
* last mapped. ISO/IEC 27001/27002 is the first framework.
*/
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, onOpenMappingState, navigation, context }: Props) {
const available = useEnginePreferences().compliance;
const revision = useRegulatoryDecisionRevision();
const id = useId();
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
const enabled = available && backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
// Returning to the tab shows the last overview of this timeslot at once; the refresh below replaces it.
const [state, setState] = useState<OverviewState>(() => {
const cached = enabled ? overviewCache.get(scopeKey) : undefined;
return cached ? { status: 'ready', overview: cached } : { status: 'idle' };
});
const [poll, setPoll] = useState(0);
// Which framework the overview row shows; the framework tiles below are unaffected.
const [framework, setFramework] = useState<ComplianceFramework>('iso');
useEffect(() => {
if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
let active = true;
const cached = overviewCache.get(scopeKey);
// A poll or a remembered overview keeps the shown values until the refreshed overview arrives.
if (cached) setState({ status: 'ready', overview: cached });
else if (poll === 0) setState({ status: 'loading' });
// A preload still in flight answers the first visit; every later visit and poll requests a fresh overview.
const pending = poll === 0 ? overviewRequests.get(scopeKey) : undefined;
(pending ?? requestOverview(projectId, assessmentIds, scanRunIds))
.then(overview => { if (active) setState({ status: 'ready', overview }); })
.catch(() => { if (active && !cached && poll === 0) setState({ status: 'error' }); });
return () => { active = false; };
}, [enabled, revision, scopeKey, poll]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setPoll(0); }, [enabled, revision, scopeKey]);
const history = state.status === 'ready' ? state.overview.mappingHistory ?? [] : [];
const mappingActive = history.some(entry => entry.status === 'queued' || entry.status === 'running');
// While a mapping of this timeslot runs, its progress and newly accepted controls appear without a reload.
useEffect(() => {
if (!mappingActive) return;
const timer = setTimeout(() => setPoll(value => value + 1), MAPPING_POLL_MS);
return () => clearTimeout(timer);
}, [mappingActive, state]);
if (!available || state.status === 'idle') return null;
const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : [];
// Pending values stay an em dash, never zero; all four themes follow the totals in ISO order.
const ready = state.status === 'ready';
const totals: DashboardFact[] = [
{ label: 'Controls', value: ready ? number(state.overview.controls.length) : '—' },
{ label: 'Findings', value: ready ? number(state.overview.findingCount) : '—' }];
const themeFindings = (theme: RegulatoryControlTheme) => groups.find(group => group.theme === theme)?.findings ?? 0;
// Same cell markup as the shared fact grid; a theme with hits opens Work filtered to it.
const tileFacts = <dl className="client-env-facts">
{totals.map(fact => <div key={fact.label}><dt>{fact.label}</dt><dd>{fact.value}</dd></div>)}
{THEMES.map(theme => {
const findings = themeFindings(theme);
return <div key={theme} className={ready && findings > 0 ? 'iso-tile-theme is-actionable' : 'iso-tile-theme'} data-theme={theme}>
<dt>{theme}</dt>
<dd>{!ready ? '—' : findings > 0
? <button type="button" aria-label={`Open ${theme} ISO theme findings in Work (${findings})`}
onClick={() => onOpenTheme(theme)}>{count(findings, 'finding')}</button>
: count(0, 'finding')}</dd>
</div>;
})}
</dl>;
const results = (dismiss: () => void) => {
const open = (action: () => void) => { dismiss(); action(); };
if (state.status !== 'ready') return null;
if (!groups.length) return <p className="iso-controls__empty">No accepted ISO controls in this timeslot.</p>;
return <div className="iso-controls">
<p className="iso-controls__total" aria-label="Unique findings with an accepted ISO control">Findings: {number(state.overview.findingCount)}</p>
<div className="iso-controls__themes" aria-label="Accepted ISO controls by theme">
{groups.map(({ theme, controls, findings }) => {
const summary = <><span className="iso-controls__theme-name">{theme ?? 'No ISO theme'}</span>
<span className="iso-controls__theme-count">{count(controls.length, 'control')} · {count(findings, 'finding')}</span></>;
return <div key={theme ?? 'none'} className="iso-controls__group" data-theme={theme ?? ''}>
{theme
? <button type="button" className="iso-controls__theme" aria-label={`Open ${theme} ISO theme findings in Work (${findings})`}
onClick={() => open(() => onOpenTheme(theme))}>{summary}</button>
: <div className="iso-controls__theme">{summary}</div>}
<div className="iso-controls__rows">{controls.map(control => <button key={control.controlId} type="button" className="iso-controls__row"
data-control={control.controlId}
aria-label={`Open ${control.annexAControlId} findings in Work (${control.findings.length})`}
onClick={() => open(() => onOpenControl(control.controlId))}>
<span className="iso-controls__id">{control.annexAControlId}</span>
<span className="iso-controls__title">{control.title ?? ''}</span>
<span className="iso-controls__amount">{number(control.findings.length)}</span>
</button>)}</div>
</div>;
})}
</div>
</div>;
};
return <div className="dashboard-page client-command-center client-compliance-page">
{navigation}
{context}
<section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<header className="client-compliance-header">
<div className="client-compliance-heading">
<h2 id={`${id}-title`}>Compliance</h2>
{state.status !== 'error' && <ComplianceFrameworkSwitch value={framework} onChange={setFramework}/>}
</div>
{state.status === 'ready' && <LastMapping history={history}/>}
</header>
{state.status !== 'error' && <ComplianceOverview framework={framework} controls={ready ? state.overview.controls : undefined}
coverage={ready ? state.overview.coverage : undefined} bsi={ready ? state.overview.bsi : undefined}
bsiFootprint={ready ? state.overview.bsiFootprint : undefined} annexATitles={ready ? state.overview.annexATitles : undefined} onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice} onOpenMappingState={onOpenMappingState}/>}
{/* Same tile geometry as the environment row; later frameworks add their tiles here. */}
<div className="client-compliance-grid" aria-label="Compliance frameworks">
<section className="client-env-group" data-framework="iso-27001-27002">
<header className="client-env-group-header">
<h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{ISO_TILE_TITLE}</span></h3>
{state.status === 'ready' && <EnvironmentResultsButton title={ISO_TILE_TITLE}>{results}</EnvironmentResultsButton>}
</header>
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts}
</section>
<BsiTile state={state} onOpenRequirement={onOpenBsiRequirement} onOpenPractice={onOpenBsiPractice}/>
{/* User decision 2026-10-09: ISO <-> BSI consistency review beside the framework tiles. */}
{projectId && <MappingReviewTile projectId={projectId} assessmentIds={assessmentIds} scanRunIds={scanRunIds} backendStatus={backendStatus}/>}
</div>
</section>
</div>;
}
// Last overview per client and timeslot, so the tab does not start empty on every visit. Every visit,
// decision and running mapping still requests a fresh overview; the bound keeps a few recent timeslots.
const OVERVIEW_CACHE_LIMIT = 8;
const overviewCache = new Map<string, RegulatoryControlOverviewResponse>();
const overviewRequests = new Map<string, Promise<RegulatoryControlOverviewResponse>>();
function requestOverview(projectId: string, assessmentIds: readonly string[], scanRunIds: readonly string[]) {
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
const request = assessorApi.getRegulatoryControlOverview(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] })
.then(overview => { rememberOverview(scopeKey, overview); return overview; })
.finally(() => { if (overviewRequests.get(scopeKey) === request) overviewRequests.delete(scopeKey); });
overviewRequests.set(scopeKey, request);
return request;
}
/**
* Client Info calls this when it opens a timeslot, so the Compliance tab already shows its overview on the first
* visit. A remembered or already requested overview is not requested again; a failure leaves the tab to load itself.
*/
export function preloadClientComplianceOverview(projectId: string, assessmentIds: readonly string[] = [], scanRunIds: readonly string[] = []): void {
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
if (overviewCache.has(scopeKey) || overviewRequests.has(scopeKey)) return;
requestOverview(projectId, assessmentIds, scanRunIds).catch(() => undefined);
}
function rememberOverview(scopeKey: string, overview: RegulatoryControlOverviewResponse) {
overviewCache.delete(scopeKey);
overviewCache.set(scopeKey, overview);
while (overviewCache.size > OVERVIEW_CACHE_LIMIT) overviewCache.delete(overviewCache.keys().next().value!);
}
/** Test support. */
export function resetClientComplianceCacheForTests(): void { overviewCache.clear(); overviewRequests.clear(); }
const MAPPING_POLL_MS = 5_000;
const triggerLabel = (entry: RegulatoryMappingHistoryEntry) =>
entry.kind === 'single' ? 'Single finding' : entry.trigger === 'automatic' ? 'Automatic' : 'Manual';
const FRAMEWORK_LABEL = { iso27001: 'ISO 27001/27002', 'bsi-kernel': 'BSI Grundschutz++' } as const;
const SCOPE_LABEL = { timeslot: 'This timeslot', 'other-timeslot': 'Other timeslot', 'other-client': 'Other client', unknown: 'Unknown' } as const;
const STATUS_LABEL = { queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' } as const;
const dateTime = (value: string) => new Date(value).toLocaleString('en-GB',
{ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
/**
* The latest mapping of any client and what started it; mappings are global per rule. The window lists the latest
* ISO and direct BSI batches and single finding mappings with their framework, scope and rule counts (user decision
* 2026-10-09); the button names BSI, an ISO mapping keeps its earlier label.
*/
function LastMapping({ history }: { history: RegulatoryMappingHistoryEntry[] }) {
const latest = history[0];
if (!latest) return <span className="client-compliance-last-mapping is-empty">No mapping yet</span>;
const label = `Last mapping: ${dateTime(latest.createdAt)}${latest.framework === 'bsi-kernel' ? ' · BSI' : ''} · ${triggerLabel(latest)}`
+ (latest.scope === 'timeslot' ? '' : ` · ${SCOPE_LABEL[latest.scope]}`)
+ (latest.status === 'completed' ? '' : ` · ${STATUS_LABEL[latest.status]}`);
return <EnvironmentResultsButton title="Mapping history" trigger={label} triggerClassName="btn client-compliance-last-mapping">
<div className="client-env-evidence-scroll">
<table className="client-information-table client-compliance-history">
<thead><tr><th>Started</th><th>Framework</th><th>Trigger</th><th>Scope</th><th>Status</th><th>Findings</th><th>Sent to model</th><th>Reused</th><th>Succeeded</th><th>Failed</th></tr></thead>
<tbody>{history.map(entry => <tr key={entry.id}>
<td>{dateTime(entry.createdAt)}</td>
<td>{FRAMEWORK_LABEL[entry.framework ?? 'iso27001']}</td>
<td>{triggerLabel(entry)}</td>
<td>{SCOPE_LABEL[entry.scope]}</td>
<td>{STATUS_LABEL[entry.status]}</td>
<td>{entry.counts.findings === undefined ? '—' : number(entry.counts.findings)}</td>
<td>{number(entry.counts.sentToModel)}</td>
<td>{number(entry.counts.reused)}</td>
<td>{number(entry.counts.succeeded)}</td>
<td>{number(entry.counts.failed)}</td>
</tr>)}</tbody>
</table>
</div>
</EnvironmentResultsButton>;
}
const BSI_TILE_PRACTICES = 4;
interface BsiPracticeGroup { id: string; title: string; requirements: Array<BsiOverview['requirements'][number] & { findings: number }>; findings: number }
/** Practices in catalog order, only with derived requirements; a finding counts once per practice. */
function bsiPracticeGroups(overview: RegulatoryControlOverviewResponse): { groups: BsiPracticeGroup[]; findings: number } {
const bsi = overview.bsi;
if (!bsi) return { groups: [], findings: 0 };
const findingsOf = bsiRequirementFindings(overview.controls, bsi);
const all = new Set<string>();
const groups = bsi.practices.map(practice => {
const practiceFindings = new Set<string>();
const requirements = bsi.requirements.filter(requirement => requirement.practice === practice.id).map(requirement => {
const findings = findingsOf.get(requirement.requirementId) ?? [];
for (const finding of findings) { practiceFindings.add(finding.resultProjectionId); all.add(finding.resultProjectionId); }
return { ...requirement, findings: findings.length };
}).sort((left, right) => right.findings - left.findings || left.requirementId.localeCompare(right.requirementId, 'en', { numeric: true }));
return { ...practice, requirements, findings: practiceFindings.size };
}).filter(group => group.requirements.length > 0);
return { groups, findings: all.size };
}
/**
* BSI Grundschutz++ tile (Phase 13F, stage 1): Kernel requirements derived from the accepted ISO controls through
* the BSI mapping. The four practices with the most findings follow the totals; the Results window lists all.
*/
function BsiTile({ state, onOpenRequirement, onOpenPractice }: { state: OverviewState; onOpenRequirement(id: string): void; onOpenPractice(id: string): void }) {
const ready = state.status === 'ready';
const { groups, findings } = ready ? bsiPracticeGroups(state.overview) : { groups: [], findings: 0 };
const bsi = ready ? state.overview.bsi : undefined;
const requirementCount = groups.reduce((total, group) => total + group.requirements.length, 0);
const top = [...groups].sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES);
const results = (dismiss: () => void) => {
const open = (action: () => void) => { dismiss(); action(); };
if (!groups.length || !bsi) return <p className="iso-controls__empty">No BSI requirements derived from accepted ISO controls in this timeslot.</p>;
return <div className="iso-controls bsi-requirements">
<p className="iso-controls__total" aria-label="Unique findings with a derived BSI requirement">Findings: {number(findings)}</p>
<div className="iso-controls__themes" aria-label="Derived BSI requirements by practice">
{groups.map(group => <div key={group.id} className="iso-controls__group" data-practice={group.id}>
<button type="button" className="iso-controls__theme" aria-label={`Open BSI practice ${group.id} findings in Work (${group.findings})`}
onClick={() => open(() => onOpenPractice(group.id))}>
<span className="iso-controls__theme-name">{group.id} · {group.title}</span>
<span className="iso-controls__theme-count">{count(group.requirements.length, 'requirement')} · {count(group.findings, 'finding')}</span>
</button>
<div className="iso-controls__rows">{group.requirements.map(requirement => <button key={requirement.requirementId} type="button" className="iso-controls__row"
data-requirement={requirement.requirementId}
aria-label={`Open ${requirement.requirementId} findings in Work (${requirement.findings})`}
onClick={() => open(() => onOpenRequirement(requirement.requirementId))}>
<span className="iso-controls__id">{requirement.requirementId}</span>
<span className="iso-controls__title">{requirement.title}
<span className="bsi-requirements__via"> · via {requirement.via.map(via => `A.${via.isoControlId}`).join(', ')}</span></span>
<span className="iso-controls__amount">{number(requirement.findings)}</span>
</button>)}</div>
</div>)}
</div>
<p className="bsi-requirements__source">
Derived from the accepted ISO controls through the BSI mapping ISO/IEC 27001 Annex A to Grundschutz++ {bsi.crosswalkVersion},
catalog {bsi.catalogVersion.slice(0, 10)}. A requirement is reached when one of its ISO controls is accepted; it is no decision of its own.
{bsi.missingTargets.length > 0 && ` Not in the catalog: ${[...new Set(bsi.missingTargets.map(item => item.requirementId))].join(', ')}.`} {bsi.license}
</p>
</div>;
};
return <section className="client-env-group" data-framework="bsi-grundschutz-plus-plus">
<header className="client-env-group-header">
<h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{BSI_TILE_TITLE}</span></h3>
{ready && <EnvironmentResultsButton title={BSI_TILE_TITLE}>{results}</EnvironmentResultsButton>}
</header>
{state.status === 'error' ? <p role="alert" className="iso-controls__error">BSI requirements could not be loaded.</p>
: <dl className="client-env-facts">
<div><dt>Requirements</dt><dd>{ready ? number(requirementCount) : '—'}</dd></div>
<div><dt>Findings</dt><dd>{ready ? number(findings) : '—'}</dd></div>
{top.map(group => <div key={group.id} className="iso-tile-theme is-actionable" data-practice={group.id}>
<dt>{group.title}</dt>
<dd><button type="button" aria-label={`Open BSI practice ${group.id} findings in Work (${group.findings})`}
onClick={() => onOpenPractice(group.id)}>{count(group.findings, 'finding')}</button></dd>
</div>)}
</dl>}
</section>;
}