diff --git a/admin-compliance/app/api/sdk/v1/master-controls/route.ts b/admin-compliance/app/api/sdk/v1/master-controls/route.ts new file mode 100644 index 0000000..5ed88c8 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/master-controls/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from 'next/server' +import { Pool } from 'pg' + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://breakpilot:breakpilot123@bp-core-postgres:5432/breakpilot_db', +}) + +/** + * GET /api/sdk/v1/master-controls?action=list|detail|members + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const action = searchParams.get('action') || 'list' + + if (action === 'list') { + return handleList(searchParams) + } + if (action === 'detail') { + return handleDetail(searchParams) + } + if (action === 'members') { + return handleMembers(searchParams) + } + + return NextResponse.json({ error: 'Unknown action' }, { status: 400 }) + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }) + } +} + +async function handleList(params: URLSearchParams) { + const search = params.get('search') || '' + const minControls = parseInt(params.get('min_controls') || '0') + const sortBy = params.get('sort') || 'total_controls' + const order = params.get('order') || 'DESC' + const limit = Math.min(parseInt(params.get('limit') || '100'), 500) + const offset = parseInt(params.get('offset') || '0') + + const validSort = ['total_controls', 'canonical_name', 'created_at'].includes(sortBy) ? sortBy : 'total_controls' + const validOrder = order === 'ASC' ? 'ASC' : 'DESC' + + let where = 'WHERE 1=1' + const args: unknown[] = [] + let idx = 1 + + if (search) { + where += ` AND mc.canonical_name ILIKE $${idx}` + args.push(`%${search}%`) + idx++ + } + if (minControls > 0) { + where += ` AND mc.total_controls >= $${idx}` + args.push(minControls) + idx++ + } + + const countRes = await pool.query( + `SELECT count(*) FROM compliance.master_controls mc ${where}`, args + ) + const total = parseInt(countRes.rows[0].count) + + args.push(limit, offset) + const res = await pool.query( + `SELECT mc.id, mc.master_control_id, mc.canonical_name, + mc.total_controls, mc.phases_covered, mc.phase_control_count + FROM compliance.master_controls mc + ${where} + ORDER BY mc.${validSort} ${validOrder} + LIMIT $${idx} OFFSET $${idx + 1}`, + args + ) + + return NextResponse.json({ + total, + limit, + offset, + master_controls: res.rows, + }) +} + +async function handleDetail(params: URLSearchParams) { + const id = params.get('id') + if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 }) + + const res = await pool.query( + `SELECT mc.id, mc.master_control_id, mc.canonical_name, + mc.total_controls, mc.phases_covered, mc.phase_control_count + FROM compliance.master_controls mc + WHERE mc.master_control_id = $1 OR mc.id::text = $1`, + [id] + ) + + if (res.rows.length === 0) { + return NextResponse.json({ error: 'not found' }, { status: 404 }) + } + + return NextResponse.json({ master_control: res.rows[0] }) +} + +async function handleMembers(params: URLSearchParams) { + const mcId = params.get('mc_id') + if (!mcId) return NextResponse.json({ error: 'mc_id required' }, { status: 400 }) + + const limit = Math.min(parseInt(params.get('limit') || '50'), 200) + const offset = parseInt(params.get('offset') || '0') + + const res = await pool.query( + `SELECT cc.control_id, cc.title, cc.objective, cc.severity, + mcm.phase, mcm.action, + COALESCE(pc.source_citation::jsonb->>'source', '') as regulation_source, + COALESCE(pc.source_citation::jsonb->>'article', '') as regulation_article + FROM compliance.master_control_members mcm + JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid + LEFT JOIN compliance.canonical_controls pc ON pc.id = cc.parent_control_uuid + WHERE mcm.master_control_uuid = ( + SELECT id FROM compliance.master_controls WHERE master_control_id = $1 OR id::text = $1 LIMIT 1 + ) + ORDER BY mcm.phase, cc.control_id + LIMIT $2 OFFSET $3`, + [mcId, limit, offset] + ) + + return NextResponse.json({ + mc_id: mcId, + members: res.rows, + count: res.rows.length, + }) +} diff --git a/admin-compliance/app/sdk/master-controls/page.tsx b/admin-compliance/app/sdk/master-controls/page.tsx new file mode 100644 index 0000000..8713525 --- /dev/null +++ b/admin-compliance/app/sdk/master-controls/page.tsx @@ -0,0 +1,266 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' + +interface MC { + id: string + master_control_id: string + canonical_name: string + total_controls: number + phases_covered: string[] + phase_control_count: Record +} + +interface Member { + control_id: string + title: string + objective: string + severity: string + phase: string + action: string + regulation_source: string + regulation_article: string +} + +const API = '/api/sdk/v1/master-controls' +const PAGE_SIZE = 50 + +const SEV_COLORS: Record = { + critical: 'bg-red-100 text-red-800', + high: 'bg-orange-100 text-orange-800', + medium: 'bg-yellow-100 text-yellow-800', + low: 'bg-blue-100 text-blue-800', +} + +export default function MasterControlsPage() { + const [mcs, setMcs] = useState([]) + const [total, setTotal] = useState(0) + const [offset, setOffset] = useState(0) + const [search, setSearch] = useState('') + const [sortBy, setSortBy] = useState('total_controls') + const [sortOrder, setSortOrder] = useState('DESC') + const [loading, setLoading] = useState(false) + + // Detail view + const [selectedMC, setSelectedMC] = useState(null) + const [members, setMembers] = useState([]) + const [membersLoading, setMembersLoading] = useState(false) + + const loadMCs = useCallback(async () => { + setLoading(true) + try { + const params = new URLSearchParams({ + action: 'list', limit: String(PAGE_SIZE), offset: String(offset), + sort: sortBy, order: sortOrder, + }) + if (search) params.set('search', search) + const res = await fetch(`${API}?${params}`) + if (res.ok) { + const data = await res.json() + setMcs(data.master_controls || []) + setTotal(data.total || 0) + } + } catch { /* ignore */ } + setLoading(false) + }, [offset, search, sortBy, sortOrder]) + + useEffect(() => { loadMCs() }, [loadMCs]) + + const loadMembers = async (mc: MC) => { + setSelectedMC(mc) + setMembersLoading(true) + try { + const res = await fetch(`${API}?action=members&mc_id=${mc.master_control_id}&limit=200`) + if (res.ok) { + const data = await res.json() + setMembers(data.members || []) + } + } catch { /* ignore */ } + setMembersLoading(false) + } + + const handleSort = (field: string) => { + if (sortBy === field) { + setSortOrder(sortOrder === 'DESC' ? 'ASC' : 'DESC') + } else { + setSortBy(field) + setSortOrder('DESC') + } + setOffset(0) + } + + const totalPages = Math.ceil(total / PAGE_SIZE) + const currentPage = Math.floor(offset / PAGE_SIZE) + 1 + + // L1 token = part before first underscore that has a sub-part + const getL1Token = (name: string) => { + const parts = name.split('_') + // Find the longest known L1 prefix + for (let i = Math.min(parts.length, 3); i >= 1; i--) { + const candidate = parts.slice(0, i).join('_') + if (['access_control', 'audit_logging', 'key_management', 'risk_management', + 'network_security', 'network_segmentation', 'multi_factor_auth', + 'transport_encryption', 'data_subject_rights', 'data_breach_notification', + 'data_processing_agreement', 'data_processing_register', + 'third_party_management', 'change_management', 'human_resources_security', + 'physical_security', 'secure_development', 'api_security', + 'input_validation', 'container_security', 'logging_configuration', + 'cookie_consent', 'video_surveillance', 'supply_chain_due_diligence', + 'critical_infrastructure', 'sustainability_reporting', + 'financial_reporting', 'consumer_protection', 'compliance_audit', + 'asset_management', 'disaster_recovery', 'patch_management', + 'password_policy', 'session_management', 'privileged_access', + 'certificate_management', 'personal_data', 'sensitive_data', + 'health_data', 'product_safety', 'medical_device', 'payment_services', + 'supervisory_authority', 'data_retention', 'data_transfer', + 'data_classification', 'privacy_by_design', + ].includes(candidate)) return candidate + } + return parts[0] + } + + return ( +
+
+
+

Master Controls

+

+ {total.toLocaleString()} Master Controls mit {mcs.reduce((s, m) => s + m.total_controls, 0).toLocaleString()}+ Atomic Controls +

+
+ + {/* Search + Filters */} +
+
+ { setSearch(e.target.value); setOffset(0) }} + className="flex-1 px-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" + /> + + Seite {currentPage} von {totalPages} + +
+
+ +
+ {/* MC List */} +
+
+ + + + + + + + + + {loading ? ( + + ) : mcs.map(mc => { + const l1 = getL1Token(mc.canonical_name) + const l2 = mc.canonical_name.slice(l1.length + 1) || '' + return ( + loadMembers(mc)} + className={`cursor-pointer hover:bg-purple-50 transition-colors ${ + selectedMC?.id === mc.id ? 'bg-purple-100' : '' + }`} + > + + + + + ) + })} + +
handleSort('canonical_name')}> + Name {sortBy === 'canonical_name' ? (sortOrder === 'ASC' ? '↑' : '↓') : ''} + handleSort('total_controls')}> + Controls {sortBy === 'total_controls' ? (sortOrder === 'ASC' ? '↑' : '↓') : ''} + Phasen
Laden...
+ + {l1} + + {l2 && ( + {l2.replace(/_/g, ' ')} + )} + + {mc.total_controls} + + {(mc.phases_covered || []).length} +
+ + {/* Pagination */} +
+ + + {offset + 1}-{Math.min(offset + PAGE_SIZE, total)} von {total} + + +
+
+
+ + {/* Member Detail Panel */} + {selectedMC && ( +
+
+
+
+

{selectedMC.canonical_name}

+

{selectedMC.total_controls} Controls, {(selectedMC.phases_covered || []).length} Phasen

+
+ +
+ +
+ {membersLoading ? ( +
Laden...
+ ) : members.map((m, i) => ( +
+
+ {m.control_id} + {m.severity && ( + + {m.severity} + + )} + {m.phase} +
+

{m.title}

+ {m.regulation_source && ( +

{m.regulation_source} {m.regulation_article}

+ )} +
+ ))} + {members.length === 0 && !membersLoading && ( +
Keine Members gefunden
+ )} +
+
+
+ )} +
+
+
+ ) +} diff --git a/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx b/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx index 5328a12..6c0f5a3 100644 --- a/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx +++ b/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx @@ -38,6 +38,21 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side } label="E-Mail-Templates" isActive={pathname === '/sdk/email-templates'} collapsed={collapsed} projectId={projectId} /> + {/* Master Controls Browser */} + + + + } + label="Master Controls" + isActive={pathname?.startsWith('/sdk/master-controls') ?? false} + collapsed={collapsed} + projectId={projectId} + /> + {/* Maschinenrecht / CE */}
{!collapsed && (