289 lines
19 KiB
TypeScript
289 lines
19 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
||
import { DonutSmallArcMarker, donutArcPath, donutPercentageLabel } from '../../components/DonutMetricCard/donut-arc';
|
||
import { DashboardGlyph } from '../../features/dashboard/ClientTileParts';
|
||
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
|
||
import { ISO_MAPPING_FAILED, ISO_MAPPING_NO_BSI, type ComplianceMappingState } from './compliance-work-facets';
|
||
import { bsiRequirementFindings, type BsiFootprint, type BsiOverview } from '../bsi-grundschutz';
|
||
import {
|
||
ANNEX_A_CONTROL_COUNTS, ANNEX_A_THEME_CLAUSES,
|
||
type RegulatoryControlOverviewItem, type RegulatoryControlTheme, type RegulatoryMappingCoverage
|
||
} from '../regulatory-mapping';
|
||
|
||
export type ComplianceFramework = 'iso' | 'bsi';
|
||
// People and Physical share one column, so the footprint stays three columns wide.
|
||
const COLUMNS: RegulatoryControlTheme[][] = [['Organizational'], ['People', 'Physical'], ['Technological']];
|
||
export const COVERAGE_TILE_TITLE = 'Mapping coverage';
|
||
export const FOOTPRINT_TILE_TITLE = 'Annex A footprint';
|
||
export const BSI_FOOTPRINT_TILE_TITLE = 'Kernel footprint';
|
||
export const BSI_FOOTPRINT_WINDOW_TITLE = 'BSI Grundschutz++ Kernel footprint';
|
||
/** Practices the BSI footprint tile shows; `Show all` shows every practice. */
|
||
const BSI_TILE_PRACTICES = 2;
|
||
const FRAMEWORKS: Array<[ComplianceFramework, string]> = [['iso', 'ISO 27001/27002'], ['bsi', 'BSI Grundschutz++']];
|
||
|
||
const number = (value: number) => value.toLocaleString('en-US');
|
||
const findingCount = (value: number) => `${number(value)} finding${value === 1 ? '' : 's'}`;
|
||
/** Shading steps of the footprint; a control or requirement without findings stays unshaded. */
|
||
export const footprintStep = (findings: number) => findings >= 25 ? 4 : findings >= 10 ? 3 : findings >= 3 ? 2 : findings >= 1 ? 1 : 0;
|
||
|
||
/** Switches the overview row between the frameworks; the framework tiles below stay as they are. */
|
||
export function ComplianceFrameworkSwitch({ value, onChange }: { value: ComplianceFramework; onChange(value: ComplianceFramework): void }) {
|
||
// The Client Info tab buttons: the approved selection control of this view.
|
||
return <div className="client-info-tabs compliance-framework-switch" role="group" aria-label="Compliance overview framework">
|
||
{FRAMEWORKS.map(([id, label]) => <button key={id} type="button" data-framework-view={id}
|
||
aria-pressed={value === id} onClick={() => onChange(id)}>{label}</button>)}
|
||
</div>;
|
||
}
|
||
|
||
interface Props {
|
||
framework: ComplianceFramework;
|
||
/** Undefined while the overview loads: values stay an em dash and nothing opens Work. */
|
||
controls?: RegulatoryControlOverviewItem[];
|
||
coverage?: RegulatoryMappingCoverage;
|
||
bsi?: BsiOverview;
|
||
bsiFootprint?: BsiFootprint;
|
||
/** Annex A titles of the active corpus; a control without findings is named by these. */
|
||
annexATitles?: Record<string, string>;
|
||
onOpenControl(controlId: string): void;
|
||
onOpenTheme(theme: RegulatoryControlTheme): void;
|
||
onOpenBsiRequirement(requirementId: string): void;
|
||
onOpenBsiPractice(practiceId: string): void;
|
||
/** Opens Work with the deviations in this `ISO mapping` state (failed, to map them again, or without a BSI requirement). */
|
||
onOpenMappingState(state: ComplianceMappingState): void;
|
||
}
|
||
|
||
/**
|
||
* Client Info `Compliance` overview row above the framework tiles, for the framework chosen by the switch: how far
|
||
* the timeslot's deviations are mapped, and where they land in the catalog. It states where findings land,
|
||
* never whether a control or requirement is met.
|
||
*/
|
||
export function ComplianceOverview(props: Props) {
|
||
const { framework, controls, coverage, bsi, onOpenMappingState } = props;
|
||
const ready = controls && coverage ? coverage : undefined;
|
||
if (framework === 'iso') {
|
||
return <div className="compliance-overview" aria-label="Compliance overview" data-framework-view="iso">
|
||
<CoverageTile caption="Deviations with an ISO control" centre={ready?.accepted} total={ready?.deviations} states={[
|
||
{ key: 'accepted', label: 'Accepted ISO control', value: ready?.accepted, arc: true },
|
||
{ key: 'mappedNoneAccepted', label: 'Mapped, none accepted', value: ready?.mappedNoneAccepted, arc: true },
|
||
{ key: 'notMapped', label: 'Not mapped yet', value: ready?.notMapped },
|
||
{ key: 'failed', label: 'Mapping failed', value: ready?.failed, arc: true, onOpen: () => onOpenMappingState(ISO_MAPPING_FAILED) }]}/>
|
||
<IsoFootprint {...props}/>
|
||
</div>;
|
||
}
|
||
// A finding with a derived requirement also has an accepted ISO control; the rest of those keep only ISO.
|
||
const withBsi = ready && controls
|
||
? new Set([...bsiRequirementFindings(controls, bsi).values()].flat().map(finding => finding.resultProjectionId)).size : undefined;
|
||
return <div className="compliance-overview" aria-label="Compliance overview" data-framework-view="bsi">
|
||
<CoverageTile caption="Deviations with a BSI requirement" centre={withBsi} total={ready?.deviations} states={[
|
||
{ key: 'bsi', label: 'With BSI requirement', value: withBsi, arc: true },
|
||
{ key: 'isoOnly', label: 'ISO control, no BSI requirement', value: ready && withBsi !== undefined ? ready.accepted - withBsi : undefined, arc: true,
|
||
onOpen: () => onOpenMappingState(ISO_MAPPING_NO_BSI) },
|
||
{ key: 'mappedNoneAccepted', label: 'Mapped, none accepted', value: ready?.mappedNoneAccepted, arc: true },
|
||
{ key: 'notMapped', label: 'Not mapped yet', value: ready?.notMapped },
|
||
{ key: 'failed', label: 'Mapping failed', value: ready?.failed, arc: true, onOpen: () => onOpenMappingState(ISO_MAPPING_FAILED) }]}/>
|
||
<BsiFootprintTile {...props}/>
|
||
</div>;
|
||
}
|
||
|
||
interface CoverageState { key: string; label: string; value?: number; arc?: boolean; onOpen?(): void }
|
||
|
||
/** The shared Client Info donut; states without an arc are the uncovered remainder drawn by the neutral track. */
|
||
function CoverageTile({ caption, centre, total, states }: { caption: string; centre?: number; total?: number; states: CoverageState[] }) {
|
||
const share = (value: number) => total ? value / total * 100 : 0;
|
||
let offset = 0;
|
||
const arcs = states.filter(state => state.arc && state.value).map(state => {
|
||
const percentage = share(state.value!);
|
||
const arcOffset = offset;
|
||
offset += percentage;
|
||
return <g key={state.key} data-state={state.key}><path className="client-info-donut-segment" d={donutArcPath(percentage, arcOffset)}/>
|
||
<DonutSmallArcMarker percentage={percentage} offset={arcOffset} className="client-info-donut-marker"/></g>;
|
||
});
|
||
const shown = (value?: number) => value === undefined ? '—' : number(value);
|
||
return <section className="client-env-group compliance-coverage" aria-labelledby="compliance-coverage-title">
|
||
<header className="client-env-group-header">
|
||
<h3 className="client-env-group-title" id="compliance-coverage-title"><DashboardGlyph name="chart"/><span>{COVERAGE_TILE_TITLE}</span></h3>
|
||
</header>
|
||
<div className="compliance-coverage__body">
|
||
<div className="compliance-coverage__ring">
|
||
<div className="client-info-donut" role="img" aria-label={`${caption}: ${shown(centre)} of ${shown(total)}`}>
|
||
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||
<circle className="client-info-donut-track" cx="60" cy="60" r="48" pathLength="100"/>
|
||
{arcs}
|
||
</svg>
|
||
<span><strong>{shown(centre)}</strong><small>{centre !== undefined && total ? donutPercentageLabel(share(centre)) : '—'}</small></span>
|
||
</div>
|
||
<span className="compliance-coverage__caption">{caption}</span>
|
||
</div>
|
||
<dl className="compliance-coverage__legend" aria-label="Mappable deviations of this timeslot by mapping state">
|
||
{states.map(state => {
|
||
// A state with an action and findings is one click target over its whole row, like an ISO tile theme.
|
||
const actionable = Boolean(state.onOpen && state.value);
|
||
return <div key={state.key} data-state={state.key} className={actionable ? 'is-actionable' : undefined}>
|
||
<i aria-hidden="true"/><dt>{state.label}</dt>
|
||
<dd>{actionable
|
||
? <button type="button" aria-label={`Open ${state.label} findings in Work (${state.value})`} onClick={state.onOpen}>{shown(state.value)}</button>
|
||
: shown(state.value)}</dd>
|
||
</div>;
|
||
})}
|
||
<div className="is-total"><i aria-hidden="true"/><dt>Mappable deviations</dt><dd>{shown(total)}</dd></div>
|
||
</dl>
|
||
</div>
|
||
<p className="compliance-coverage__note">Shows how far mapping has progressed, not whether controls are met.</p>
|
||
</section>;
|
||
}
|
||
|
||
interface FootprintDetail { id: string; group: string; title?: string; via?: string; findings: number; reachable: boolean }
|
||
|
||
/** Shared footprint frame: hover or focus names a cell in the detail line below the grid. */
|
||
function FootprintFrame({ title, glyph, action, children }: { title: string; glyph: 'shield' | 'policy'; action?: ReactNode; children: ReactNode }) {
|
||
return <section className="client-env-group compliance-footprint" aria-labelledby="compliance-footprint-title">
|
||
<header className="client-env-group-header">
|
||
<h3 className="client-env-group-title" id="compliance-footprint-title"><DashboardGlyph name={glyph}/><span>{title}</span></h3>
|
||
{action}
|
||
</header>
|
||
{children}
|
||
</section>;
|
||
}
|
||
|
||
/** Hover or focus names a cell here, below the grid. */
|
||
function FootprintDetailLine({ current }: { current?: FootprintDetail }) {
|
||
return <div className="compliance-footprint__detail" aria-live="polite">
|
||
{current
|
||
? <><div><div className="compliance-footprint__detail-id">{[current.id, current.group, current.via].filter(Boolean).join(' · ')}</div>
|
||
{current.title && <div className="compliance-footprint__detail-title">{current.title}</div>}</div>
|
||
<div className="compliance-footprint__detail-count">{current.findings > 0 ? findingCount(current.findings)
|
||
: current.reachable ? 'No mapped findings' : 'Not reachable through the ISO mapping'}</div></>
|
||
: <div className="compliance-footprint__detail-hint">Hover or focus a cell to see its title and findings.</div>}
|
||
</div>;
|
||
}
|
||
|
||
function FootprintLegend({ empty, unreachable }: { empty?: string; unreachable?: string }) {
|
||
return <div className="compliance-footprint__legend" aria-hidden="true">
|
||
{empty && <span><i className="compliance-footprint__cell"/>{empty}</span>}
|
||
{unreachable && <span><i className="compliance-footprint__cell is-unreachable"/>{unreachable}</span>}
|
||
{(['1–2', '3–9', '10–24', '25+ findings'] as const).map((label, index) =>
|
||
<span key={label}><i className="compliance-footprint__cell" data-step={index + 1}/>{label}</span>)}
|
||
</div>;
|
||
}
|
||
|
||
function IsoFootprint({ controls, annexATitles, onOpenControl, onOpenTheme }: Props) {
|
||
const [current, setCurrent] = useState<FootprintDetail>();
|
||
const byId = new Map((controls ?? []).map(control => [control.controlId, control]));
|
||
const theme = (name: RegulatoryControlTheme) => {
|
||
const cells = Array.from({ length: ANNEX_A_CONTROL_COUNTS[name] }, (_, index) => {
|
||
const controlId = `${ANNEX_A_THEME_CLAUSES[name]}.${index + 1}`;
|
||
return { controlId, control: byId.get(controlId) };
|
||
});
|
||
const hits = cells.filter(cell => cell.control?.findings.length);
|
||
// A finding with several controls of one theme counts once for that theme.
|
||
const findings = new Set(hits.flatMap(cell => cell.control!.findings.map(finding => finding.resultProjectionId))).size;
|
||
const summary = <><span className="compliance-footprint__theme-name">{name}</span>
|
||
<span className="compliance-footprint__theme-meta">{controls
|
||
? `${number(hits.length)} of ${number(cells.length)} controls · ${findingCount(findings)}` : '—'}</span></>;
|
||
return <div key={name} className="compliance-footprint__theme" data-iso-theme={name}>
|
||
{/* Like the ISO tile, only a theme with hits opens Work: the ISO theme facet offers only values with hits. */}
|
||
{findings > 0
|
||
? <button type="button" className="compliance-footprint__theme-head" aria-label={`Open ${name} ISO theme findings in Work (${findings})`}
|
||
onClick={() => onOpenTheme(name)}>{summary}</button>
|
||
: <div className="compliance-footprint__theme-head">{summary}</div>}
|
||
<div className="compliance-footprint__cells">{cells.map(({ controlId, control }) => {
|
||
const count = control?.findings.length ?? 0;
|
||
const className = `compliance-footprint__cell${current?.id === `A.${controlId}` ? ' is-current' : ''}`;
|
||
const show = () => setCurrent({ id: `A.${controlId}`, group: name, title: control?.title ?? annexATitles?.[controlId], findings: count, reachable: true });
|
||
return count > 0
|
||
? <button key={controlId} type="button" className={className} data-control={controlId} data-step={footprintStep(count)}
|
||
aria-label={`Open A.${controlId}${control?.title ? ` ${control.title}` : ''} findings in Work (${count})`}
|
||
onMouseEnter={show} onFocus={show} onClick={() => onOpenControl(controlId)}>{controlId}</button>
|
||
: <span key={controlId} className={className} data-control={controlId} aria-hidden="true" onMouseEnter={show}>{controlId}</span>;
|
||
})}</div>
|
||
</div>;
|
||
};
|
||
return <FootprintFrame title={FOOTPRINT_TILE_TITLE} glyph="shield">
|
||
<div className="compliance-footprint__themes">
|
||
{COLUMNS.map(column => <div key={column.join()} className="compliance-footprint__column">{column.map(theme)}</div>)}
|
||
</div>
|
||
<FootprintDetailLine current={current}/>
|
||
<FootprintLegend empty="No mapped findings (not a compliance statement)"/>
|
||
</FootprintFrame>;
|
||
}
|
||
|
||
interface BsiGridProps {
|
||
practices: BsiFootprint['practices'];
|
||
findingsOf: Map<string, Array<{ resultProjectionId: string }>>;
|
||
derived: Map<string, BsiOverview['requirements'][number]>;
|
||
onOpenRequirement(requirementId: string): void;
|
||
onOpenPractice(practiceId: string): void;
|
||
}
|
||
|
||
/**
|
||
* BSI Kernel requirements by practice in catalog order, each a square labelled with its number inside the practice.
|
||
* `hits` (the tile) shows the two practices with the most findings; `all` (the Show all window) shows every practice, larger. A
|
||
* requirement the BSI mapping cannot reach from any ISO control is marked, so an empty square never reads as a gap of this client.
|
||
*/
|
||
function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequirement, onOpenPractice }: BsiGridProps & { mode: 'hits' | 'all' }) {
|
||
const [current, setCurrent] = useState<FootprintDetail>();
|
||
const blocks = practices.map(practice => {
|
||
const findings = new Set<string>();
|
||
const hits = practice.requirements.filter(requirement => {
|
||
const list = findingsOf.get(requirement.id) ?? [];
|
||
for (const finding of list) findings.add(finding.resultProjectionId);
|
||
return list.length > 0;
|
||
});
|
||
return { practice, findings: findings.size, cells: practice.requirements, hits: hits.length };
|
||
});
|
||
// The tile keeps the practices with the most findings, like the BSI tile; ties stay in catalog order.
|
||
if (mode === 'hits') blocks.splice(0, blocks.length, ...blocks.filter(block => block.hits > 0)
|
||
.sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES));
|
||
if (!blocks.length) return <p className="compliance-footprint__pending">No BSI requirements derived from accepted ISO controls in this timeslot.</p>;
|
||
return <>
|
||
<div className={`compliance-footprint__practices${mode === 'all' ? ' is-window' : ''}`}>
|
||
{blocks.map(({ practice, findings, cells, hits }) => {
|
||
const summary = <><span className="compliance-footprint__theme-name">{practice.id} · {practice.title}</span>
|
||
<span className="compliance-footprint__theme-meta">{`${number(hits)} of ${number(practice.requirements.length)} requirements · ${findingCount(findings)}`}</span></>;
|
||
return <div key={practice.id} className="compliance-footprint__theme" data-bsi-practice={practice.id}>
|
||
{findings > 0
|
||
? <button type="button" className="compliance-footprint__theme-head" aria-label={`Open BSI practice ${practice.id} findings in Work (${findings})`}
|
||
onClick={() => onOpenPractice(practice.id)}>{summary}</button>
|
||
: <div className="compliance-footprint__theme-head">{summary}</div>}
|
||
<div className="compliance-footprint__requirements">{cells.map(requirement => {
|
||
const count = findingsOf.get(requirement.id)?.length ?? 0;
|
||
const known = derived.get(requirement.id);
|
||
const className = `compliance-footprint__cell${requirement.reachable ? '' : ' is-unreachable'}${current?.id === requirement.id ? ' is-current' : ''}`;
|
||
const show = () => setCurrent({ id: requirement.id, group: `${practice.id} ${practice.title}`, title: known?.title ?? requirement.title,
|
||
via: known ? `via ${known.via.map(via => `A.${via.isoControlId}`).join(', ')}` : undefined, findings: count, reachable: requirement.reachable });
|
||
// The practice heading names the prefix, so a square shows only the number, like an Annex A cell.
|
||
const label = requirement.id.slice(practice.id.length + 1);
|
||
return count > 0
|
||
? <button key={requirement.id} type="button" className={className} data-requirement={requirement.id} data-step={footprintStep(count)}
|
||
aria-label={`Open ${requirement.id}${known ? ` ${known.title}` : ''} findings in Work (${count})`}
|
||
onMouseEnter={show} onFocus={show} onClick={() => onOpenRequirement(requirement.id)}>{label}</button>
|
||
: <span key={requirement.id} className={className} data-requirement={requirement.id} aria-hidden="true" onMouseEnter={show}>{label}</span>;
|
||
})}</div>
|
||
</div>;
|
||
})}
|
||
</div>
|
||
{/* In the Show all window this stays pinned below the scrolling grid, so a hovered square is always named. */}
|
||
<div className="compliance-footprint__footer">
|
||
<FootprintDetailLine current={current}/>
|
||
<FootprintLegend empty="Reachable, no mapped findings" unreachable="Not reachable through the ISO mapping"/>
|
||
</div>
|
||
</>;
|
||
}
|
||
|
||
/** The tile shows the two practices with the most findings; `Show all` opens every practice in a window. */
|
||
function BsiFootprintTile({ controls, bsi, bsiFootprint, onOpenBsiRequirement, onOpenBsiPractice }: Props) {
|
||
const findingsOf = controls ? bsiRequirementFindings(controls, bsi) : new Map<string, RegulatoryControlOverviewItem['findings']>();
|
||
const derived = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement]));
|
||
const grid = { findingsOf, derived };
|
||
const ready = controls && bsiFootprint;
|
||
return <FootprintFrame title={BSI_FOOTPRINT_TILE_TITLE} glyph="policy" action={ready && <EnvironmentResultsButton title={BSI_FOOTPRINT_WINDOW_TITLE} trigger="Show all">
|
||
{dismiss => <div className="compliance-footprint compliance-footprint--window">
|
||
<BsiRequirementGrid mode="all" practices={bsiFootprint.practices} {...grid}
|
||
onOpenRequirement={id => { dismiss(); onOpenBsiRequirement(id); }} onOpenPractice={id => { dismiss(); onOpenBsiPractice(id); }}/>
|
||
</div>}
|
||
</EnvironmentResultsButton>}>
|
||
{ready
|
||
? <BsiRequirementGrid mode="hits" practices={bsiFootprint.practices} {...grid} onOpenRequirement={onOpenBsiRequirement} onOpenPractice={onOpenBsiPractice}/>
|
||
: <p className="compliance-footprint__pending">—</p>}
|
||
</FootprintFrame>;
|
||
}
|