c5c168592b
Per project_sdk_module_attribution_matrix.md the Stufe-3 rollout is
prioritized by audit visibility. This batch covers Schritte 2-9 in one
sweep:
New reusable component:
components/sdk/LicenseModuleBanner.tsx — single-line license banner
placed at the top of an SDK module page. Renders rule pill (R1/R2/R3),
source label, descriptor and link to /sdk/licenses. Replaces the
copy-paste banner blocks I inlined in the earlier modules.
Integration points (per cluster):
Cluster B (DSGVO/EU-Recht, R1):
- vvt: existing "Vorlage" pill upgraded with R1 marker + tooltip
explaining Bundeslaender-DSGVO provenance
- dsfa: inline R1 banner citing DSGVO Art. 35
Cluster C (EU AI Act / CRA, R1):
- ai-act: inline R1 banner citing EU 2024/1689
- cra: inline R1 banner citing EU 2024/2847 + ENISA-Guidance
Cluster D (Mix R2/R3):
- isms: R3 banner + ISO/IEC 27001 reference disclaimer
- security-backlog: R2 banner with OWASP CC-BY-SA attribution
Cluster A (Eigenwerk, R3):
- tom-generator: R1 source (DSGVO Art. 32) + R3 own-work disclaimer
- audit-checklist: R3 banner for own audit methodology
- document-generator: own templates R3 + cited rights R1
Cluster E (Direct controls listing):
- catalog-manager: System/User tag upgraded with rule classification
- iace hazards: pattern_id pill upgraded with R3 + tooltip explaining
BreakPilot Pattern-Engine provenance
The 11-module sweep brings audit transparency to the modules a paying
customer encounters most often. Stufe 3 of the attribution renderer
is now actually visible across the platform — previously it shipped
only the reusable <SourceBadge> component without integration points.
Pre-existing TS errors (drafting-engine constraint-enforcer, dsfa
types tests) untouched — not in scope for this licensing rollout.
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import {
|
|
Search,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Eye,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
Database,
|
|
} from 'lucide-react'
|
|
import type { CatalogMeta, CatalogEntry } from '@/lib/sdk/catalog-manager/types'
|
|
|
|
interface CatalogTableProps {
|
|
catalog: CatalogMeta
|
|
entries: CatalogEntry[]
|
|
searchQuery: string
|
|
onSearchChange: (query: string) => void
|
|
onEditCustomEntry: (entry: CatalogEntry) => void
|
|
onDeleteCustomEntry: (entryId: string) => void
|
|
onAddEntry: () => void
|
|
}
|
|
|
|
type SortField = 'id' | 'name' | 'category' | 'type'
|
|
type SortDirection = 'asc' | 'desc'
|
|
|
|
export default function CatalogTable({
|
|
catalog,
|
|
entries,
|
|
searchQuery,
|
|
onSearchChange,
|
|
onEditCustomEntry,
|
|
onDeleteCustomEntry,
|
|
onAddEntry,
|
|
}: CatalogTableProps) {
|
|
const [sortField, setSortField] = useState<SortField>('name')
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
|
|
|
const handleSort = (field: SortField) => {
|
|
if (sortField === field) {
|
|
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'))
|
|
} else {
|
|
setSortField(field)
|
|
setSortDirection('asc')
|
|
}
|
|
}
|
|
|
|
const sortedEntries = useMemo(() => {
|
|
const sorted = [...entries].sort((a, b) => {
|
|
let aVal = ''
|
|
let bVal = ''
|
|
|
|
switch (sortField) {
|
|
case 'id':
|
|
aVal = a.id
|
|
bVal = b.id
|
|
break
|
|
case 'name':
|
|
aVal = a.displayName
|
|
bVal = b.displayName
|
|
break
|
|
case 'category':
|
|
aVal = a.category || ''
|
|
bVal = b.category || ''
|
|
break
|
|
case 'type':
|
|
aVal = a.source
|
|
bVal = b.source
|
|
break
|
|
}
|
|
|
|
const cmp = aVal.localeCompare(bVal, 'de')
|
|
return sortDirection === 'asc' ? cmp : -cmp
|
|
})
|
|
|
|
return sorted
|
|
}, [entries, sortField, sortDirection])
|
|
|
|
const SortIcon = ({ field }: { field: SortField }) => {
|
|
if (sortField !== field) {
|
|
return (
|
|
<span className="ml-1 inline-flex flex-col opacity-30">
|
|
<ChevronUp className="h-3 w-3 -mb-1" />
|
|
<ChevronDown className="h-3 w-3" />
|
|
</span>
|
|
)
|
|
}
|
|
return sortDirection === 'asc' ? (
|
|
<ChevronUp className="ml-1 h-3.5 w-3.5 inline" />
|
|
) : (
|
|
<ChevronDown className="ml-1 h-3.5 w-3.5 inline" />
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Search & Add Header */}
|
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
<div className="relative w-full sm:w-80">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => onSearchChange(e.target.value)}
|
|
placeholder="Eintraege durchsuchen..."
|
|
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{catalog.allowCustom && (
|
|
<button
|
|
onClick={onAddEntry}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors shrink-0"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Eintrag hinzufuegen
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
|
<th
|
|
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
|
|
onClick={() => handleSort('name')}
|
|
>
|
|
Name
|
|
<SortIcon field="name" />
|
|
</th>
|
|
{catalog.categoryField && (
|
|
<th
|
|
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
|
|
onClick={() => handleSort('category')}
|
|
>
|
|
Kategorie
|
|
<SortIcon field="category" />
|
|
</th>
|
|
)}
|
|
<th
|
|
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
|
|
onClick={() => handleSort('type')}
|
|
>
|
|
Quelle
|
|
<SortIcon field="type" />
|
|
</th>
|
|
<th className="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
|
Aktionen
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedEntries.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={catalog.categoryField ? 4 : 3}
|
|
className="px-4 py-12 text-center text-gray-500 dark:text-gray-400"
|
|
>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Database className="h-8 w-8 opacity-40" />
|
|
<p className="text-sm">
|
|
{searchQuery
|
|
? `Keine Eintraege gefunden fuer "${searchQuery}"`
|
|
: 'Keine Eintraege vorhanden'}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sortedEntries.map((entry) => (
|
|
<tr
|
|
key={`${entry.source}-${entry.id}`}
|
|
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"
|
|
>
|
|
<td className="px-4 py-2.5">
|
|
<div>
|
|
<p className="text-gray-900 dark:text-gray-100 font-medium truncate max-w-xs">
|
|
{entry.displayName}
|
|
</p>
|
|
{entry.displayDescription && (
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs mt-0.5">
|
|
{entry.displayDescription}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</td>
|
|
{catalog.categoryField && (
|
|
<td className="px-4 py-2.5 text-gray-600 dark:text-gray-300">
|
|
{entry.category || '\u2014'}
|
|
</td>
|
|
)}
|
|
<td className="px-4 py-2.5">
|
|
{entry.source === 'system' ? (
|
|
<span
|
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 cursor-help"
|
|
title="System-Katalog — Quellen aus EU-Recht, BAuA, NIST u.a. Lizenzregel je Eintrag (siehe /sdk/licenses)."
|
|
>
|
|
System · R1/R2/R3
|
|
</span>
|
|
) : (
|
|
<span
|
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 cursor-help"
|
|
title="Benutzerdefinierter Eintrag — BreakPilot/Anwender-Eigenwerk. Lizenzregel R3 (Identifier-Verweis), keine externe Attribution noetig."
|
|
>
|
|
Benutzerdefiniert · R3
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{entry.source === 'system' ? (
|
|
<button
|
|
onClick={() => onEditCustomEntry(entry)}
|
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded transition-colors"
|
|
title="Details anzeigen"
|
|
>
|
|
<Eye className="h-3.5 w-3.5" />
|
|
Details
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => onEditCustomEntry(entry)}
|
|
className="inline-flex items-center p-1.5 text-gray-500 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded transition-colors"
|
|
title="Bearbeiten"
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => onDeleteCustomEntry(entry.id)}
|
|
className="inline-flex items-center p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
|
title="Loeschen"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Footer with count */}
|
|
{sortedEntries.length > 0 && (
|
|
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
|
|
{sortedEntries.length} {sortedEntries.length === 1 ? 'Eintrag' : 'Eintraege'}
|
|
{searchQuery && ` (gefiltert)`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|