diff --git a/admin-compliance/app/sdk/control-library/components/ControlListItem.tsx b/admin-compliance/app/sdk/control-library/components/ControlListItem.tsx new file mode 100644 index 0000000..dca137e --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/ControlListItem.tsx @@ -0,0 +1,74 @@ +'use client' + +import { ChevronRight, BookOpen, Clock } from 'lucide-react' +import { CanonicalControl, SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge, GenerationStrategyBadge, ObligationTypeBadge } from './helpers' + +interface ControlListItemProps { + ctrl: CanonicalControl + sortBy: string + prevSource: string | null + onClick: () => void +} + +export function ControlListItem({ ctrl, sortBy, prevSource, onClick }: ControlListItemProps) { + const curSource = ctrl.source_citation?.source || 'Ohne Quelle' + const showSourceHeader = sortBy === 'source' && curSource !== prevSource + + return ( +
+ {showSourceHeader && ( +
+
+ {curSource} +
+
+ )} + +
+ ) +} diff --git a/admin-compliance/app/sdk/control-library/components/ControlsHeader.tsx b/admin-compliance/app/sdk/control-library/components/ControlsHeader.tsx new file mode 100644 index 0000000..aef560c --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/ControlsHeader.tsx @@ -0,0 +1,232 @@ +'use client' + +import { Shield, Lock, ListChecks, Trash2, BarChart3, Zap, Plus, RefreshCw, Search, Filter, ArrowUpDown } from 'lucide-react' +import { Framework } from './helpers' +import { ControlsMeta } from './types' +import { VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS } from './helpers' + +interface ControlsHeaderProps { + frameworks: Framework[] + meta: ControlsMeta | null + reviewCount: number + loading: boolean + bulkProcessing: boolean + showStats: boolean + processedStats: Array> + searchQuery: string + severityFilter: string + domainFilter: string + stateFilter: string + hideDuplicates: boolean + verificationFilter: string + categoryFilter: string + evidenceTypeFilter: string + audienceFilter: string + sourceFilter: string + typeFilter: string + sortBy: string + onSearchChange: (v: string) => void + onSeverityChange: (v: string) => void + onDomainChange: (v: string) => void + onStateChange: (v: string) => void + onHideDuplicatesChange: (v: boolean) => void + onVerificationChange: (v: string) => void + onCategoryChange: (v: string) => void + onEvidenceTypeChange: (v: string) => void + onAudienceChange: (v: string) => void + onSourceChange: (v: string) => void + onTypeChange: (v: string) => void + onSortChange: (v: string) => void + onRefresh: () => void + onEnterReviewMode: () => void + onBulkReject: (state: string) => void + onToggleStats: () => void + onOpenGenerator: () => void + onCreateNew: () => void +} + +export function ControlsHeader({ + frameworks, meta, reviewCount, loading, bulkProcessing, showStats, processedStats, + searchQuery, severityFilter, domainFilter, stateFilter, hideDuplicates, + verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, sortBy, + onSearchChange, onSeverityChange, onDomainChange, onStateChange, onHideDuplicatesChange, + onVerificationChange, onCategoryChange, onEvidenceTypeChange, onAudienceChange, onSourceChange, onTypeChange, onSortChange, + onRefresh, onEnterReviewMode, onBulkReject, onToggleStats, onOpenGenerator, onCreateNew, +}: ControlsHeaderProps) { + return ( +
+
+
+ +
+

Canonical Control Library

+

{meta?.total ?? 0} Security Controls

+
+
+
+ {reviewCount > 0 && ( + <> + + + + )} + + + +
+
+ + {frameworks.length > 0 && ( +
+
+ + {frameworks[0]?.name} v{frameworks[0]?.version} + + {frameworks[0]?.description} +
+
+ )} + +
+
+
+ + onSearchChange(e.target.value)} + className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500" /> +
+ +
+
+ + + + + + + + + + + + | + + +
+
+ + {showStats && processedStats.length > 0 && ( +
+

Verarbeitungsfortschritt

+
+ {processedStats.map((s, i) => ( +
+ {String(s.collection)} +
+ {String(s.processed_chunks)} verarbeitet + {String(s.direct_adopted)} direkt + {String(s.llm_reformed)} reformuliert +
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/control-library/components/types.ts b/admin-compliance/app/sdk/control-library/components/types.ts new file mode 100644 index 0000000..1748ea0 --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/types.ts @@ -0,0 +1,38 @@ +// Shared types for control-library page + +export interface ControlsMeta { + total: number + domains: Array<{ domain: string; count: number }> + sources: Array<{ source: string; count: number }> + no_source_count: number + type_counts?: { + rich: number + atomic: number + eigenentwicklung: number + } + severity_counts?: Record + verification_method_counts?: Record + category_counts?: Record + evidence_type_counts?: Record + release_state_counts?: Record +} + +export interface ControlFormData { + title: string + objective: string + severity: string + domain: string + release_state: string + verification_method: string + category: string + evidence_type: string + target_audience: string + license_rule: string + risk_score: number | null + implementation_effort: number | null + open_anchors: Array<{ framework: string; ref: string; url: string }> + requirements: string[] + test_procedure: string[] + evidence: Array<{ type: string; description: string }> + [key: string]: unknown +} diff --git a/admin-compliance/app/sdk/control-library/components/useControlLibrary.ts b/admin-compliance/app/sdk/control-library/components/useControlLibrary.ts new file mode 100644 index 0000000..a24dd91 --- /dev/null +++ b/admin-compliance/app/sdk/control-library/components/useControlLibrary.ts @@ -0,0 +1,292 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import { CanonicalControl, Framework, BACKEND_URL } from './helpers' +import { ControlsMeta, ControlFormData } from './types' + +const PAGE_SIZE = 50 + +export function useControlLibrary() { + const [frameworks, setFrameworks] = useState([]) + const [controls, setControls] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [meta, setMeta] = useState(null) + const [selectedControl, setSelectedControl] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Filters + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [severityFilter, setSeverityFilter] = useState('') + const [domainFilter, setDomainFilter] = useState('') + const [stateFilter, setStateFilter] = useState('') + const [verificationFilter, setVerificationFilter] = useState('') + const [categoryFilter, setCategoryFilter] = useState('') + const [evidenceTypeFilter, setEvidenceTypeFilter] = useState('') + const [audienceFilter, setAudienceFilter] = useState('') + const [sourceFilter, setSourceFilter] = useState('') + const [typeFilter, setTypeFilter] = useState('') + const [hideDuplicates, setHideDuplicates] = useState(true) + const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id') + + // CRUD state + const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list') + const [saving, setSaving] = useState(false) + + // Generator state + const [showGenerator, setShowGenerator] = useState(false) + const [processedStats, setProcessedStats] = useState>>([]) + const [showStats, setShowStats] = useState(false) + + // Pagination + const [currentPage, setCurrentPage] = useState(1) + + // Review mode + const [reviewMode, setReviewMode] = useState(false) + const [reviewIndex, setReviewIndex] = useState(0) + const [reviewItems, setReviewItems] = useState([]) + const [reviewCount, setReviewCount] = useState(0) + const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates') + const [reviewDuplicates, setReviewDuplicates] = useState([]) + const [reviewRule3, setReviewRule3] = useState([]) + + // V1 Compare mode + const [compareMode, setCompareMode] = useState(false) + const [compareV1Control, setCompareV1Control] = useState(null) + const [compareMatches, setCompareMatches] = useState | null + similarity_score: number; match_rank: number; match_method: string + }>>([]) + + const [bulkProcessing, setBulkProcessing] = useState(false) + + // Abort controllers + const metaAbortRef = useRef(null) + const controlsAbortRef = useRef(null) + + // Debounce search + const searchTimer = useRef | null>(null) + useEffect(() => { + if (searchTimer.current) clearTimeout(searchTimer.current) + searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400) + return () => { if (searchTimer.current) clearTimeout(searchTimer.current) } + }, [searchQuery]) + + const buildParams = useCallback((extra?: Record) => { + const p = new URLSearchParams() + if (severityFilter) p.set('severity', severityFilter) + if (domainFilter) p.set('domain', domainFilter) + if (stateFilter) p.set('release_state', stateFilter) + if (verificationFilter) p.set('verification_method', verificationFilter) + if (categoryFilter) p.set('category', categoryFilter) + if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter) + if (audienceFilter) p.set('target_audience', audienceFilter) + if (sourceFilter) p.set('source', sourceFilter) + if (typeFilter) p.set('control_type', typeFilter) + if (hideDuplicates) p.set('exclude_duplicates', 'true') + if (debouncedSearch) p.set('search', debouncedSearch) + if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v) + return p.toString() + }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch]) + + const loadFrameworks = useCallback(async () => { + try { + const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`) + if (res.ok) setFrameworks(await res.json()) + } catch { /* ignore */ } + }, []) + + const loadMeta = useCallback(async () => { + if (metaAbortRef.current) metaAbortRef.current.abort() + const controller = new AbortController() + metaAbortRef.current = controller + try { + const qs = buildParams() + const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal }) + if (res.ok && !controller.signal.aborted) setMeta(await res.json()) + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return + } + }, [buildParams]) + + const loadControls = useCallback(async () => { + if (controlsAbortRef.current) controlsAbortRef.current.abort() + const controller = new AbortController() + controlsAbortRef.current = controller + try { + setLoading(true) + const sortField = sortBy === 'id' ? 'control_id' : sortBy === 'source' ? 'source' : 'created_at' + const sortOrder = sortBy === 'newest' ? 'desc' : sortBy === 'oldest' ? 'asc' : 'asc' + const offset = (currentPage - 1) * PAGE_SIZE + const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) }) + const countQs = buildParams() + const [ctrlRes, countRes] = await Promise.all([ + fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }), + fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }), + ]) + if (!controller.signal.aborted) { + if (ctrlRes.ok) setControls(await ctrlRes.json()) + if (countRes.ok) { + const data = await countRes.json() + setTotalCount(data.total || 0) + } + } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + setError(err instanceof Error ? err.message : 'Fehler beim Laden') + } finally { + if (!controller.signal.aborted) setLoading(false) + } + }, [buildParams, sortBy, currentPage]) + + const loadReviewCount = useCallback(async () => { + try { + const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`) + if (res.ok) { + const data = await res.json() + setReviewCount(data.total || 0) + } + } catch { /* ignore */ } + }, []) + + useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount]) + useEffect(() => { loadMeta() }, [loadMeta]) + useEffect(() => { loadControls() }, [loadControls]) + useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy]) + + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) + + const fullReload = useCallback(async () => { + await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()]) + }, [loadControls, loadMeta, loadFrameworks, loadReviewCount]) + + const handleCreate = async (data: ControlFormData) => { + setSaving(true) + try { + const res = await fetch(`${BACKEND_URL}?endpoint=create-control`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), + }) + if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return } + await fullReload(); setMode('list') + } catch { alert('Netzwerkfehler') } finally { setSaving(false) } + } + + const handleUpdate = async (data: ControlFormData) => { + if (!selectedControl) return + setSaving(true) + try { + const res = await fetch(`${BACKEND_URL}?endpoint=update-control&id=${selectedControl.control_id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), + }) + if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return } + await fullReload(); setSelectedControl(null); setMode('list') + } catch { alert('Netzwerkfehler') } finally { setSaving(false) } + } + + const handleDelete = async (controlId: string) => { + if (!confirm(`Control ${controlId} wirklich loeschen?`)) return + try { + const res = await fetch(`${BACKEND_URL}?id=${controlId}`, { method: 'DELETE' }) + if (!res.ok && res.status !== 204) { alert('Fehler beim Loeschen'); return } + await fullReload(); setSelectedControl(null); setMode('list') + } catch { alert('Netzwerkfehler') } + } + + const handleReview = async (controlId: string, action: string) => { + try { + const res = await fetch(`${BACKEND_URL}?endpoint=review&id=${controlId}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }), + }) + if (res.ok) { + await fullReload() + if (reviewMode) { + const remaining = reviewItems.filter(c => c.control_id !== controlId) + setReviewItems(remaining) + if (remaining.length > 0) { + const nextIdx = Math.min(reviewIndex, remaining.length - 1) + setReviewIndex(nextIdx); setSelectedControl(remaining[nextIdx]) + } else { setReviewMode(false); setSelectedControl(null); setMode('list') } + } else { setSelectedControl(null); setMode('list') } + } + } catch { /* ignore */ } + } + + const handleBulkReject = async (sourceState: string) => { + const count = stateFilter === sourceState ? totalCount : reviewCount + if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return + setBulkProcessing(true) + try { + const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ release_state: sourceState, action: 'reject' }), + }) + if (res.ok) { const data = await res.json(); alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`); await fullReload() } + else { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) } + } catch { alert('Netzwerkfehler') } finally { setBulkProcessing(false) } + } + + const loadProcessedStats = async () => { + try { + const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`) + if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) } + } catch { /* ignore */ } + } + + const enterReviewMode = async () => { + try { + const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`) + if (res.ok) { + const items: CanonicalControl[] = await res.json() + if (items.length > 0) { + const dupes = items.filter(c => + c.generation_metadata?.similar_controls && + Array.isArray(c.generation_metadata.similar_controls) && + (c.generation_metadata.similar_controls as unknown[]).length > 0 + ) + const rule3 = items.filter(c => + !c.generation_metadata?.similar_controls || + !Array.isArray(c.generation_metadata.similar_controls) || + (c.generation_metadata.similar_controls as unknown[]).length === 0 + ) + setReviewDuplicates(dupes); setReviewRule3(rule3) + const startTab = dupes.length > 0 ? 'duplicates' : 'rule3' + const startItems = startTab === 'duplicates' ? dupes : rule3 + setReviewTab(startTab); setReviewItems(startItems); setReviewMode(true) + setReviewIndex(0); setSelectedControl(startItems[0]); setMode('detail') + } + } + } catch { /* ignore */ } + } + + const switchReviewTab = (tab: 'duplicates' | 'rule3') => { + const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3 + setReviewTab(tab); setReviewItems(items); setReviewIndex(0) + if (items.length > 0) setSelectedControl(items[0]) + } + + return { + // State + frameworks, controls, totalCount, meta, selectedControl, setSelectedControl, + loading, error, searchQuery, setSearchQuery, debouncedSearch, + severityFilter, setSeverityFilter, domainFilter, setDomainFilter, + stateFilter, setStateFilter, verificationFilter, setVerificationFilter, + categoryFilter, setCategoryFilter, evidenceTypeFilter, setEvidenceTypeFilter, + audienceFilter, setAudienceFilter, sourceFilter, setSourceFilter, + typeFilter, setTypeFilter, hideDuplicates, setHideDuplicates, + sortBy, setSortBy, mode, setMode, saving, + showGenerator, setShowGenerator, processedStats, showStats, setShowStats, + currentPage, setCurrentPage, totalPages, + reviewMode, setReviewMode, reviewIndex, setReviewIndex, + reviewItems, setReviewItems, reviewCount, reviewTab, setReviewTab, + reviewDuplicates, reviewRule3, bulkProcessing, + compareMode, setCompareMode, compareV1Control, setCompareV1Control, compareMatches, setCompareMatches, + // Actions + fullReload, loadControls, loadMeta, loadFrameworks, loadReviewCount, + loadProcessedStats, handleCreate, handleUpdate, handleDelete, + handleReview, handleBulkReject, enterReviewMode, switchReviewTab, + } +} diff --git a/admin-compliance/app/sdk/control-library/page.tsx b/admin-compliance/app/sdk/control-library/page.tsx index d658547..fcba6a0 100644 --- a/admin-compliance/app/sdk/control-library/page.tsx +++ b/admin-compliance/app/sdk/control-library/page.tsx @@ -1,495 +1,77 @@ 'use client' -import { useState, useEffect, useCallback, useRef } from 'react' -import { - Shield, Search, ChevronRight, ChevronLeft, Filter, Lock, - BookOpen, Plus, Zap, BarChart3, ListChecks, Trash2, - ChevronsLeft, ChevronsRight, ArrowUpDown, Clock, RefreshCw, -} from 'lucide-react' -import { - CanonicalControl, Framework, BACKEND_URL, EMPTY_CONTROL, - SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge, - GenerationStrategyBadge, ObligationTypeBadge, - VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS, TARGET_AUDIENCE_OPTIONS, -} from './components/helpers' +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react' +import { EMPTY_CONTROL } from './components/helpers' import { ControlForm } from './components/ControlForm' import { ControlDetail } from './components/ControlDetail' import { ReviewCompare } from './components/ReviewCompare' import { V1CompareView } from './components/V1CompareView' import { GeneratorModal } from './components/GeneratorModal' - -// ============================================================================= -// TYPES -// ============================================================================= - -interface ControlsMeta { - total: number - domains: Array<{ domain: string; count: number }> - sources: Array<{ source: string; count: number }> - no_source_count: number - type_counts?: { - rich: number - atomic: number - eigenentwicklung: number - } - severity_counts?: Record - verification_method_counts?: Record - category_counts?: Record - evidence_type_counts?: Record - release_state_counts?: Record -} - -// ============================================================================= -// CONTROL LIBRARY PAGE — Server-Side Pagination -// ============================================================================= - -const PAGE_SIZE = 50 +import { ControlsHeader } from './components/ControlsHeader' +import { ControlListItem } from './components/ControlListItem' +import { useControlLibrary } from './components/useControlLibrary' +import { BACKEND_URL } from './components/helpers' export default function ControlLibraryPage() { - const [frameworks, setFrameworks] = useState([]) - const [controls, setControls] = useState([]) - const [totalCount, setTotalCount] = useState(0) - const [meta, setMeta] = useState(null) - const [selectedControl, setSelectedControl] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const lib = useControlLibrary() - // Filters - const [searchQuery, setSearchQuery] = useState('') - const [debouncedSearch, setDebouncedSearch] = useState('') - const [severityFilter, setSeverityFilter] = useState('') - const [domainFilter, setDomainFilter] = useState('') - const [stateFilter, setStateFilter] = useState('') - const [verificationFilter, setVerificationFilter] = useState('') - const [categoryFilter, setCategoryFilter] = useState('') - const [evidenceTypeFilter, setEvidenceTypeFilter] = useState('') - const [audienceFilter, setAudienceFilter] = useState('') - const [sourceFilter, setSourceFilter] = useState('') - const [typeFilter, setTypeFilter] = useState('') - const [hideDuplicates, setHideDuplicates] = useState(true) - const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id') - - // CRUD state - const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list') - const [saving, setSaving] = useState(false) - - // Generator state - const [showGenerator, setShowGenerator] = useState(false) - const [processedStats, setProcessedStats] = useState>>([]) - const [showStats, setShowStats] = useState(false) - - // Pagination - const [currentPage, setCurrentPage] = useState(1) - - // Review mode - const [reviewMode, setReviewMode] = useState(false) - const [reviewIndex, setReviewIndex] = useState(0) - const [reviewItems, setReviewItems] = useState([]) - const [reviewCount, setReviewCount] = useState(0) - const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates') - const [reviewDuplicates, setReviewDuplicates] = useState([]) - const [reviewRule3, setReviewRule3] = useState([]) - - // V1 Compare mode - const [compareMode, setCompareMode] = useState(false) - const [compareV1Control, setCompareV1Control] = useState(null) - const [compareMatches, setCompareMatches] = useState | null - similarity_score: number; match_rank: number; match_method: string - }>>([]) - - // Abort controllers for cancelling stale requests - const metaAbortRef = useRef(null) - const controlsAbortRef = useRef(null) - - // Debounce search - const searchTimer = useRef | null>(null) - useEffect(() => { - if (searchTimer.current) clearTimeout(searchTimer.current) - searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400) - return () => { if (searchTimer.current) clearTimeout(searchTimer.current) } - }, [searchQuery]) - - // Build query params for backend - const buildParams = useCallback((extra?: Record) => { - const p = new URLSearchParams() - if (severityFilter) p.set('severity', severityFilter) - if (domainFilter) p.set('domain', domainFilter) - if (stateFilter) p.set('release_state', stateFilter) - if (verificationFilter) p.set('verification_method', verificationFilter) - if (categoryFilter) p.set('category', categoryFilter) - if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter) - if (audienceFilter) p.set('target_audience', audienceFilter) - if (sourceFilter) p.set('source', sourceFilter) - if (typeFilter) p.set('control_type', typeFilter) - if (hideDuplicates) p.set('exclude_duplicates', 'true') - if (debouncedSearch) p.set('search', debouncedSearch) - if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v) - return p.toString() - }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch]) - - // Load frameworks (once) - const loadFrameworks = useCallback(async () => { - try { - const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`) - if (res.ok) setFrameworks(await res.json()) - } catch { /* ignore */ } - }, []) - - // Load faceted metadata (reloads when filters change, cancels stale requests) - const loadMeta = useCallback(async () => { - if (metaAbortRef.current) metaAbortRef.current.abort() - const controller = new AbortController() - metaAbortRef.current = controller - try { - const qs = buildParams() - const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal }) - if (res.ok && !controller.signal.aborted) setMeta(await res.json()) - } catch (e) { - if (e instanceof DOMException && e.name === 'AbortError') return - } - }, [buildParams]) - - // Load controls page (cancels stale requests) - const loadControls = useCallback(async () => { - if (controlsAbortRef.current) controlsAbortRef.current.abort() - const controller = new AbortController() - controlsAbortRef.current = controller - try { - setLoading(true) - - // Determine sort - const sortField = sortBy === 'id' ? 'control_id' : sortBy === 'source' ? 'source' : 'created_at' - const sortOrder = sortBy === 'newest' ? 'desc' : sortBy === 'oldest' ? 'asc' : 'asc' - const offset = (currentPage - 1) * PAGE_SIZE - - const qs = buildParams({ - sort: sortField, - order: sortOrder, - limit: String(PAGE_SIZE), - offset: String(offset), - }) - - const countQs = buildParams() - - const [ctrlRes, countRes] = await Promise.all([ - fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }), - fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }), - ]) - - if (!controller.signal.aborted) { - if (ctrlRes.ok) setControls(await ctrlRes.json()) - if (countRes.ok) { - const data = await countRes.json() - setTotalCount(data.total || 0) - } - } - } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') return - setError(err instanceof Error ? err.message : 'Fehler beim Laden') - } finally { - if (!controller.signal.aborted) setLoading(false) - } - }, [buildParams, sortBy, currentPage]) - - // Load review count - const loadReviewCount = useCallback(async () => { - try { - const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`) - if (res.ok) { - const data = await res.json() - setReviewCount(data.total || 0) - } - } catch { /* ignore */ } - }, []) - - // Initial load (frameworks only once) - useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount]) - - // Load faceted meta when filters change - useEffect(() => { loadMeta() }, [loadMeta]) - - // Load controls when filters/page/sort change - useEffect(() => { loadControls() }, [loadControls]) - - // Reset page when filters change - useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy]) - - // Pagination - const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)) - - // Full reload (after CRUD) - const fullReload = useCallback(async () => { - await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()]) - }, [loadControls, loadMeta, loadFrameworks, loadReviewCount]) - - // CRUD handlers - const handleCreate = async (data: typeof EMPTY_CONTROL) => { - setSaving(true) - try { - const res = await fetch(`${BACKEND_URL}?endpoint=create-control`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) { - const err = await res.json() - alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) - return - } - await fullReload() - setMode('list') - } catch { - alert('Netzwerkfehler') - } finally { - setSaving(false) - } - } - - const handleUpdate = async (data: typeof EMPTY_CONTROL) => { - if (!selectedControl) return - setSaving(true) - try { - const res = await fetch(`${BACKEND_URL}?endpoint=update-control&id=${selectedControl.control_id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - if (!res.ok) { - const err = await res.json() - alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) - return - } - await fullReload() - setSelectedControl(null) - setMode('list') - } catch { - alert('Netzwerkfehler') - } finally { - setSaving(false) - } - } - - const handleDelete = async (controlId: string) => { - if (!confirm(`Control ${controlId} wirklich loeschen?`)) return - try { - const res = await fetch(`${BACKEND_URL}?id=${controlId}`, { method: 'DELETE' }) - if (!res.ok && res.status !== 204) { - alert('Fehler beim Loeschen') - return - } - await fullReload() - setSelectedControl(null) - setMode('list') - } catch { - alert('Netzwerkfehler') - } - } - - const handleReview = async (controlId: string, action: string) => { - try { - const res = await fetch(`${BACKEND_URL}?endpoint=review&id=${controlId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action }), - }) - if (res.ok) { - await fullReload() - if (reviewMode) { - const remaining = reviewItems.filter(c => c.control_id !== controlId) - setReviewItems(remaining) - if (remaining.length > 0) { - const nextIdx = Math.min(reviewIndex, remaining.length - 1) - setReviewIndex(nextIdx) - setSelectedControl(remaining[nextIdx]) - } else { - setReviewMode(false) - setSelectedControl(null) - setMode('list') - } - } else { - setSelectedControl(null) - setMode('list') - } - } - } catch { /* ignore */ } - } - - const [bulkProcessing, setBulkProcessing] = useState(false) - - const handleBulkReject = async (sourceState: string) => { - const count = stateFilter === sourceState ? totalCount : reviewCount - if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return - setBulkProcessing(true) - try { - const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ release_state: sourceState, action: 'reject' }), - }) - if (res.ok) { - const data = await res.json() - alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`) - await fullReload() - } else { - const err = await res.json() - alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) - } - } catch { - alert('Netzwerkfehler') - } finally { - setBulkProcessing(false) - } - } - - const loadProcessedStats = async () => { - try { - const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`) - if (res.ok) { - const data = await res.json() - setProcessedStats(data.stats || []) - } - } catch { /* ignore */ } - } - - const enterReviewMode = async () => { - // Load review items from backend - try { - const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`) - if (res.ok) { - const items: CanonicalControl[] = await res.json() - if (items.length > 0) { - // Split into duplicate suspects vs rule 3 without anchor - const dupes = items.filter(c => - c.generation_metadata?.similar_controls && - Array.isArray(c.generation_metadata.similar_controls) && - (c.generation_metadata.similar_controls as unknown[]).length > 0 - ) - const rule3 = items.filter(c => - !c.generation_metadata?.similar_controls || - !Array.isArray(c.generation_metadata.similar_controls) || - (c.generation_metadata.similar_controls as unknown[]).length === 0 - ) - setReviewDuplicates(dupes) - setReviewRule3(rule3) - // Start with duplicates tab if any, otherwise rule3 - const startTab = dupes.length > 0 ? 'duplicates' : 'rule3' - const startItems = startTab === 'duplicates' ? dupes : rule3 - setReviewTab(startTab) - setReviewItems(startItems) - setReviewMode(true) - setReviewIndex(0) - setSelectedControl(startItems[0]) - setMode('detail') - } - } - } catch { /* ignore */ } - } - - const switchReviewTab = (tab: 'duplicates' | 'rule3') => { - const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3 - setReviewTab(tab) - setReviewItems(items) - setReviewIndex(0) - if (items.length > 0) { - setSelectedControl(items[0]) - } - } - - // Loading - if (loading && controls.length === 0) { + if (lib.loading && lib.controls.length === 0) { return (
) } - - if (error) { - return ( -
-

{error}

-
- ) + if (lib.error) { + return

{lib.error}

} - // CREATE/EDIT MODE - if (mode === 'create') { - return setMode('list')} saving={saving} /> + if (lib.mode === 'create') { + return lib.setMode('list')} saving={lib.saving} /> } - - if (mode === 'edit' && selectedControl) { + if (lib.mode === 'edit' && lib.selectedControl) { return ( 0 - ? selectedControl.open_anchors - : [{ framework: '', ref: '', url: '' }], - requirements: selectedControl.requirements.length > 0 ? selectedControl.requirements : [''], - test_procedure: selectedControl.test_procedure.length > 0 ? selectedControl.test_procedure : [''], - evidence: selectedControl.evidence.length > 0 ? selectedControl.evidence : [{ type: '', description: '' }], + ...EMPTY_CONTROL, ...lib.selectedControl, + risk_score: lib.selectedControl.risk_score, + implementation_effort: lib.selectedControl.implementation_effort, + open_anchors: lib.selectedControl.open_anchors.length > 0 ? lib.selectedControl.open_anchors : [{ framework: '', ref: '', url: '' }], + requirements: lib.selectedControl.requirements.length > 0 ? lib.selectedControl.requirements : [''], + test_procedure: lib.selectedControl.test_procedure.length > 0 ? lib.selectedControl.test_procedure : [''], + evidence: lib.selectedControl.evidence.length > 0 ? lib.selectedControl.evidence : [{ type: '', description: '' }], }} - onSave={handleUpdate} - onCancel={() => { setMode('detail') }} - saving={saving} + onSave={lib.handleUpdate} onCancel={() => lib.setMode('detail')} saving={lib.saving} /> ) } - // V1 COMPARE MODE - if (compareMode && compareV1Control) { + if (lib.compareMode && lib.compareV1Control) { return ( { setCompareMode(false) }} + v1Control={lib.compareV1Control} matches={lib.compareMatches} + onBack={() => lib.setCompareMode(false)} onNavigateToControl={async (controlId: string) => { try { const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`) - if (res.ok) { - setCompareMode(false) - setSelectedControl(await res.json()) - setMode('detail') - } + if (res.ok) { lib.setCompareMode(false); lib.setSelectedControl(await res.json()); lib.setMode('detail') } } catch { /* ignore */ } }} /> ) } - // DETAIL MODE - if (mode === 'detail' && selectedControl) { - const isDuplicateReview = reviewMode && reviewTab === 'duplicates' - - // Review tab bar (shown above the detail/compare view in review mode) - const reviewTabBar = reviewMode ? ( + if (lib.mode === 'detail' && lib.selectedControl) { + const isDuplicateReview = lib.reviewMode && lib.reviewTab === 'duplicates' + const reviewTabBar = lib.reviewMode ? (
- -
) : null @@ -500,22 +82,12 @@ export default function ControlLibraryPage() { {reviewTabBar}
{ setMode('list'); setSelectedControl(null); setReviewMode(false) }} - onReview={handleReview} - onEdit={() => setMode('edit')} - reviewIndex={reviewIndex} - reviewTotal={reviewItems.length} - onReviewPrev={() => { - const idx = Math.max(0, reviewIndex - 1) - setReviewIndex(idx) - setSelectedControl(reviewItems[idx]) - }} - onReviewNext={() => { - const idx = Math.min(reviewItems.length - 1, reviewIndex + 1) - setReviewIndex(idx) - setSelectedControl(reviewItems[idx]) - }} + ctrl={lib.selectedControl} + onBack={() => { lib.setMode('list'); lib.setSelectedControl(null); lib.setReviewMode(false) }} + onReview={lib.handleReview} onEdit={() => lib.setMode('edit')} + reviewIndex={lib.reviewIndex} reviewTotal={lib.reviewItems.length} + onReviewPrev={() => { const idx = Math.max(0, lib.reviewIndex - 1); lib.setReviewIndex(idx); lib.setSelectedControl(lib.reviewItems[idx]) }} + onReviewNext={() => { const idx = Math.min(lib.reviewItems.length - 1, lib.reviewIndex + 1); lib.setReviewIndex(idx); lib.setSelectedControl(lib.reviewItems[idx]) }} />
@@ -527,469 +99,122 @@ export default function ControlLibraryPage() { {reviewTabBar}
{ setMode('list'); setSelectedControl(null); setReviewMode(false) }} - onEdit={() => setMode('edit')} - onDelete={handleDelete} - onReview={handleReview} - onRefresh={fullReload} - onCompare={(ctrl, matches) => { - setCompareV1Control(ctrl) - setCompareMatches(matches) - setCompareMode(true) - }} + ctrl={lib.selectedControl} + onBack={() => { lib.setMode('list'); lib.setSelectedControl(null); lib.setReviewMode(false) }} + onEdit={() => lib.setMode('edit')} onDelete={lib.handleDelete} + onReview={lib.handleReview} onRefresh={lib.fullReload} + onCompare={(ctrl, matches) => { lib.setCompareV1Control(ctrl); lib.setCompareMatches(matches); lib.setCompareMode(true) }} onNavigateToControl={async (controlId: string) => { try { const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`) - if (res.ok) { - const data = await res.json() - setSelectedControl(data) - setMode('detail') - } + if (res.ok) { lib.setSelectedControl(await res.json()); lib.setMode('detail') } } catch { /* ignore */ } }} - reviewMode={reviewMode} - reviewIndex={reviewIndex} - reviewTotal={reviewItems.length} - onReviewPrev={() => { - const idx = Math.max(0, reviewIndex - 1) - setReviewIndex(idx) - setSelectedControl(reviewItems[idx]) - }} - onReviewNext={() => { - const idx = Math.min(reviewItems.length - 1, reviewIndex + 1) - setReviewIndex(idx) - setSelectedControl(reviewItems[idx]) - }} + reviewMode={lib.reviewMode} reviewIndex={lib.reviewIndex} reviewTotal={lib.reviewItems.length} + onReviewPrev={() => { const idx = Math.max(0, lib.reviewIndex - 1); lib.setReviewIndex(idx); lib.setSelectedControl(lib.reviewItems[idx]) }} + onReviewNext={() => { const idx = Math.min(lib.reviewItems.length - 1, lib.reviewIndex + 1); lib.setReviewIndex(idx); lib.setSelectedControl(lib.reviewItems[idx]) }} />
) } - // ========================================================================= // LIST VIEW - // ========================================================================= - return (
- {/* Header */} -
-
-
- -
-

Canonical Control Library

-

- {meta?.total ?? totalCount} Security Controls -

-
-
-
- {reviewCount > 0 && ( - <> - - - - )} - - - -
-
+ lib.setSortBy(v as 'id' | 'newest' | 'oldest' | 'source')} + onRefresh={() => { lib.loadControls(); lib.loadMeta(); lib.loadFrameworks(); lib.loadReviewCount() }} + onEnterReviewMode={lib.enterReviewMode} onBulkReject={lib.handleBulkReject} + onToggleStats={() => { lib.setShowStats(!lib.showStats); if (!lib.showStats) lib.loadProcessedStats() }} + onOpenGenerator={() => lib.setShowGenerator(true)} onCreateNew={() => lib.setMode('create')} + /> - {/* Frameworks */} - {frameworks.length > 0 && ( -
-
- - {frameworks[0]?.name} v{frameworks[0]?.version} - - {frameworks[0]?.description} -
-
- )} - - {/* Filters */} -
-
-
- - setSearchQuery(e.target.value)} - className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500" - /> -
- -
-
- - - - - - - - - - - - | - - -
-
- - {/* Processing Stats */} - {showStats && processedStats.length > 0 && ( -
-

Verarbeitungsfortschritt

-
- {processedStats.map((s, i) => ( -
- {String(s.collection)} -
- {String(s.processed_chunks)} verarbeitet - {String(s.direct_adopted)} direkt - {String(s.llm_reformed)} reformuliert -
-
- ))} -
-
- )} -
- - {/* Generator Modal */} - {showGenerator && ( - setShowGenerator(false)} - onComplete={() => fullReload()} - /> + {lib.showGenerator && ( + lib.setShowGenerator(false)} onComplete={() => lib.fullReload()} /> )} - {/* Pagination Header */}
- {totalCount} Controls gefunden - {totalCount !== (meta?.total ?? totalCount) && ` (von ${meta?.total} gesamt)`} - {loading && Lade...} + {lib.totalCount} Controls gefunden + {lib.totalCount !== (lib.meta?.total ?? lib.totalCount) && ` (von ${lib.meta?.total} gesamt)`} + {lib.loading && Lade...} - {stateFilter && ['needs_review', 'too_close', 'duplicate'].includes(stateFilter) && totalCount > 0 && ( - )}
- Seite {currentPage} von {totalPages} + Seite {lib.currentPage} von {lib.totalPages}
- {/* Control List */}
- {controls.map((ctrl, idx) => { - // Show source group header when sorting by source - const prevSource = idx > 0 ? (controls[idx - 1].source_citation?.source || 'Ohne Quelle') : null - const curSource = ctrl.source_citation?.source || 'Ohne Quelle' - const showSourceHeader = sortBy === 'source' && curSource !== prevSource - - return ( -
- {showSourceHeader && ( -
-
- {curSource} -
-
- )} - -
- ) - })} - - {controls.length === 0 && !loading && ( + {lib.controls.map((ctrl, idx) => ( + 0 ? (lib.controls[idx - 1].source_citation?.source || 'Ohne Quelle') : null} + onClick={() => { lib.setSelectedControl(ctrl); lib.setMode('detail') }} + /> + ))} + {lib.controls.length === 0 && !lib.loading && (
- {totalCount === 0 && !debouncedSearch && !severityFilter && !domainFilter + {lib.totalCount === 0 && !lib.debouncedSearch && !lib.severityFilter && !lib.domainFilter ? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.' : 'Keine Controls gefunden.'}
)}
- {/* Pagination Controls */} - {totalPages > 1 && ( + {lib.totalPages > 1 && (
- - - - {/* Page numbers */} - {Array.from({ length: totalPages }, (_, i) => i + 1) - .filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2) + {Array.from({ length: lib.totalPages }, (_, i) => i + 1) + .filter(p => p === 1 || p === lib.totalPages || Math.abs(p - lib.currentPage) <= 2) .reduce<(number | 'dots')[]>((acc, p, i, arr) => { if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots') - acc.push(p) - return acc + acc.push(p); return acc }, []) - .map((p, i) => - p === 'dots' ? ( - ... - ) : ( - - ) - ) - } - - + ))} + -
diff --git a/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentForm.tsx b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentForm.tsx new file mode 100644 index 0000000..c370bee --- /dev/null +++ b/admin-compliance/app/sdk/iace/[projectId]/components/_components/ComponentForm.tsx @@ -0,0 +1,80 @@ +'use client' + +import { useState } from 'react' +import { Component, ComponentFormData, COMPONENT_TYPES } from './types' + +export function ComponentForm({ + onSubmit, onCancel, initialData, parentId, +}: { + onSubmit: (data: ComponentFormData) => void + onCancel: () => void + initialData?: Component | null + parentId?: string | null +}) { + const [formData, setFormData] = useState({ + name: initialData?.name || '', + type: initialData?.type || 'SW', + version: initialData?.version || '', + description: initialData?.description || '', + safety_relevant: initialData?.safety_relevant || false, + parent_id: parentId || initialData?.parent_id || null, + }) + + return ( +
+

+ {initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'} +

+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="z.B. Bildverarbeitungsmodul" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" /> +
+
+ + +
+
+ + setFormData({ ...formData, version: e.target.value })} + placeholder="z.B. 1.2.0" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" /> +
+
+