dfac940272
Backend
- backend-compliance/compliance/api/licenses_routes.py: three endpoints
built on the now-complete license_rule classification
- GET /api/compliance/licenses/overview
global aggregation by rule + per-source breakdown (Stufe 1)
- POST /api/compliance/licenses/aggregate
per-control-set aggregation for PDF footer (Stufe 2) and
tech-file appendix (Stufe 4) — consumed later
- GET /api/compliance/licenses/source-info/{control_uuid}
single-control lookup for the inline source badge (Stufe 3)
- registered in api/__init__.py via the existing safe-import loader
Frontend
- app/sdk/licenses/page.tsx (Stufe 1): the /sdk/licenses overview page.
Renders rule legend cards + per-rule source tables. Drives the
/licenses footer link and gives auditors a one-page view of what
licence classes the platform is operating under.
- components/sdk/SourceBadge.tsx (Stufe 3): reusable React component.
Small R1/R2/R3 pill with click-expand tooltip showing source
regulation + attribution string + render-full-text policy. Will be
embedded into IACE hazards/mitigations, VVT items, DSFA controls in
follow-up commits.
Two stages of the four-stage renderer are now ready. Stufe 2 (PDF
auto-footer) + Stufe 4 (tech-file appendix) follow once the existing
PDF generators are extended to call /licenses/aggregate.
139 lines
5.0 KiB
TypeScript
139 lines
5.0 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
|
|
// Stufe 3 of the Attribution Renderer (Task #23): an inline source
|
|
// badge that any rendered control/hazard/measure can attach to itself.
|
|
//
|
|
// Visually a small license-rule pill (R1/R2/R3); on hover/click it
|
|
// reveals the underlying regulation, license type, and — for Rule 2 —
|
|
// the mandatory attribution string.
|
|
//
|
|
// Usage:
|
|
// <SourceBadge controlUuid={hazard.id} />
|
|
//
|
|
// The component lazily fetches /licenses/source-info/{uuid} on first
|
|
// expand so the surrounding list view stays cheap.
|
|
|
|
type SourceInfo = {
|
|
control_uuid: string
|
|
license_rule: number | null
|
|
license_label_de: string | null
|
|
attribution_required: boolean
|
|
render_full_text: boolean
|
|
regulation_id: string | null
|
|
regulation_name_de: string | null
|
|
license_type: string | null
|
|
attribution: string | null
|
|
source_url: string | null
|
|
}
|
|
|
|
const RULE_BADGE: Record<number, string> = {
|
|
1: 'bg-emerald-100 text-emerald-800 border-emerald-300',
|
|
2: 'bg-amber-100 text-amber-800 border-amber-300',
|
|
3: 'bg-slate-100 text-slate-700 border-slate-300',
|
|
}
|
|
|
|
const RULE_TITLE: Record<number, string> = {
|
|
1: 'R1 — wörtlich übernehmbar',
|
|
2: 'R2 — wörtlich mit Attribution',
|
|
3: 'R3 — nur Identifier zitieren',
|
|
}
|
|
|
|
interface SourceBadgeProps {
|
|
controlUuid: string
|
|
/** Optional: skip the fetch and render from already-known data. */
|
|
prefetched?: SourceInfo
|
|
/** Compact mode for tight UI rows (smaller pill). */
|
|
compact?: boolean
|
|
}
|
|
|
|
export function SourceBadge({ controlUuid, prefetched, compact }: SourceBadgeProps) {
|
|
const [data, setData] = useState<SourceInfo | null>(prefetched ?? null)
|
|
const [open, setOpen] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open || data) return
|
|
setLoading(true)
|
|
fetch(`/api/sdk/v1/compliance/licenses/source-info/${controlUuid}`)
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`)))
|
|
.then(setData)
|
|
.catch((e) => setError(String(e)))
|
|
.finally(() => setLoading(false))
|
|
}, [open, data, controlUuid])
|
|
|
|
const rule = data?.license_rule ?? prefetched?.license_rule ?? null
|
|
const badgeClass = rule ? RULE_BADGE[rule] ?? RULE_BADGE[3] : 'bg-slate-100 text-slate-500 border-slate-200'
|
|
const sizeClass = compact ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-0.5'
|
|
|
|
return (
|
|
<span className="relative inline-block">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className={`inline-flex items-center gap-1 rounded border font-medium ${sizeClass} ${badgeClass} hover:opacity-80 transition`}
|
|
title={rule ? RULE_TITLE[rule] : 'Lizenz unbekannt'}
|
|
aria-expanded={open}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" aria-hidden>
|
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0Zm0 4.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM7 8h2v4.5H7V8Z" />
|
|
</svg>
|
|
{rule ? `R${rule}` : '?'}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 mt-1 z-40 w-80 rounded-md border border-slate-200 bg-white shadow-lg p-3 text-xs">
|
|
{loading && <p className="text-slate-500">Lade Quellen-Info…</p>}
|
|
{error && <p className="text-red-600">Fehler: {error}</p>}
|
|
{data && (
|
|
<div className="space-y-2">
|
|
<div className="font-semibold text-slate-800">
|
|
{data.license_label_de ?? 'Lizenz unbekannt'}
|
|
</div>
|
|
{data.regulation_name_de && (
|
|
<div>
|
|
<span className="text-slate-500">Quelle:</span>{' '}
|
|
<span className="text-slate-800">{data.regulation_name_de}</span>
|
|
</div>
|
|
)}
|
|
{data.license_type && (
|
|
<div>
|
|
<span className="text-slate-500">Lizenztyp:</span>{' '}
|
|
<span className="text-slate-700">{data.license_type}</span>
|
|
</div>
|
|
)}
|
|
{data.attribution && (
|
|
<div className="rounded bg-amber-50 border border-amber-200 px-2 py-1.5">
|
|
<div className="text-[10px] font-semibold text-amber-800 uppercase tracking-wide">
|
|
Attribution-Pflicht
|
|
</div>
|
|
<div className="text-amber-900">{data.attribution}</div>
|
|
</div>
|
|
)}
|
|
{!data.render_full_text && (
|
|
<div className="text-[10px] text-slate-500 italic">
|
|
Volltext wird im Output nicht gerendert — nur Identifier-Verweis.
|
|
</div>
|
|
)}
|
|
{data.source_url && (
|
|
<a
|
|
href={data.source_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-block text-[10px] text-blue-600 hover:underline mt-1"
|
|
>
|
|
Originalquelle öffnen ↗
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export default SourceBadge
|