From b8770e1b9cf649bf4c6e7541412c9a17b6615f1f Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 11 May 2026 20:02:31 +0200 Subject: [PATCH] feat(mc-browser): reuse Control Library UI for Master Controls - MC page.tsx imports ControlListView + useControlLibraryState directly - useControlLibraryState accepts optional backendUrl override - MC API route returns data in canonical control format - Same filters, pagination, sorting, click-to-detail as Control Library Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/api/sdk/v1/master-controls/route.ts | 230 +++++++---- .../components/useControlLibraryState.ts | 17 +- .../app/sdk/master-controls/page.tsx | 360 +++++------------- 3 files changed, 263 insertions(+), 344 deletions(-) diff --git a/admin-compliance/app/api/sdk/v1/master-controls/route.ts b/admin-compliance/app/api/sdk/v1/master-controls/route.ts index 5ed88c8..25044c2 100644 --- a/admin-compliance/app/api/sdk/v1/master-controls/route.ts +++ b/admin-compliance/app/api/sdk/v1/master-controls/route.ts @@ -6,41 +6,46 @@ const pool = new Pool({ }) /** - * GET /api/sdk/v1/master-controls?action=list|detail|members + * MC API that returns data in the same format as the canonical controls + * endpoint. This allows the MC page to reuse ControlListView components. */ export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) - const action = searchParams.get('action') || 'list' + const endpoint = searchParams.get('endpoint') || 'controls' - if (action === 'list') { - return handleList(searchParams) - } - if (action === 'detail') { - return handleDetail(searchParams) - } - if (action === 'members') { - return handleMembers(searchParams) - } + switch (endpoint) { + case 'frameworks': + return NextResponse.json([]) - return NextResponse.json({ error: 'Unknown action' }, { status: 400 }) + case 'controls': + return handleControls(searchParams) + + case 'controls-count': + return handleCount(searchParams) + + case 'controls-meta': + return handleMeta(searchParams) + + case 'control': + return handleDetail(searchParams) + + default: + return NextResponse.json({ error: 'unknown' }, { status: 400 }) + } } catch (e) { return NextResponse.json({ error: String(e) }, { status: 500 }) } } -async function handleList(params: URLSearchParams) { +async function handleControls(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 limit = Math.min(parseInt(params.get('limit') || '50'), 200) const offset = parseInt(params.get('offset') || '0') + const sort = params.get('sort') || 'control_id' + const order = params.get('order') === 'desc' ? 'DESC' : 'ASC' - const validSort = ['total_controls', 'canonical_name', 'created_at'].includes(sortBy) ? sortBy : 'total_controls' - const validOrder = order === 'ASC' ? 'ASC' : 'DESC' - - let where = 'WHERE 1=1' + let where = "WHERE 1=1" const args: unknown[] = [] let idx = 1 @@ -49,81 +54,150 @@ async function handleList(params: URLSearchParams) { 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) + const sortCol = sort === 'control_id' ? 'mc.master_control_id' : + sort === 'created_at' ? 'mc.created_at' : 'mc.master_control_id' args.push(limit, offset) + const res = await pool.query(` + SELECT mc.master_control_id as control_id, + mc.canonical_name as title, + 'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective, + CASE WHEN mc.total_controls > 100 THEN 'high' + WHEN mc.total_controls > 20 THEN 'medium' + ELSE 'low' END as severity, + 'master_control' as category, + mc.total_controls, + mc.phases_covered, + mc.id, + mc.created_at + FROM compliance.master_controls mc + ${where} + ORDER BY ${sortCol} ${order} + LIMIT $${idx} OFFSET $${idx + 1} + `, args) + + // Map to canonical control format + const controls = res.rows.map(r => ({ + id: r.id, + control_id: r.control_id, + title: r.title, + objective: r.objective, + severity: r.severity, + category: r.category, + release_state: 'active', + source_citation: null, + verification_method: null, + evidence_type: null, + target_audience: [], + requirements: [], + test_procedure: [], + evidence: [], + open_anchors: [], + total_controls: r.total_controls, + phases_covered: r.phases_covered, + created_at: r.created_at, + })) + + return NextResponse.json(controls) +} + +async function handleCount(params: URLSearchParams) { + const search = params.get('search') || '' + let where = "WHERE 1=1" + const args: unknown[] = [] + + if (search) { + where += ` AND mc.canonical_name ILIKE $1` + args.push(`%${search}%`) + } + 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 + `SELECT count(*) FROM compliance.master_controls mc ${where}`, args ) + return NextResponse.json({ total: parseInt(res.rows[0].count) }) +} + +async function handleMeta(params: URLSearchParams) { + const res = await pool.query(` + SELECT count(*) as total, + count(CASE WHEN total_controls > 100 THEN 1 END) as high_count, + count(CASE WHEN total_controls BETWEEN 20 AND 100 THEN 1 END) as medium_count, + count(CASE WHEN total_controls < 20 THEN 1 END) as low_count + FROM compliance.master_controls + `) + const r = res.rows[0] + + // Get top L1 tokens as "domains" + const domainRes = await pool.query(` + SELECT split_part(canonical_name, '_', 1) as domain, count(*) as count + FROM compliance.master_controls + GROUP BY 1 ORDER BY 2 DESC LIMIT 30 + `) return NextResponse.json({ - total, - limit, - offset, - master_controls: res.rows, + total: parseInt(r.total), + severity_counts: { + high: parseInt(r.high_count), + medium: parseInt(r.medium_count), + low: parseInt(r.low_count), + }, + domains: domainRes.rows.map(d => ({ domain: d.domain, count: parseInt(d.count) })), + sources: [], + no_source_count: 0, + release_state_counts: { active: parseInt(r.total) }, + verification_method_counts: {}, + category_counts: {}, + evidence_type_counts: {}, }) } 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] - ) + const id = params.get('id') || '' + const res = await pool.query(` + SELECT mc.id, mc.master_control_id as control_id, mc.canonical_name as title, + 'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective, + mc.total_controls, mc.phases_covered, mc.phase_control_count, mc.created_at + 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] }) -} + const mc = 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] - ) + // Load members + const membersRes = await pool.query(` + SELECT cc.control_id, cc.title, cc.severity, mcm.phase, mcm.action + FROM compliance.master_control_members mcm + JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid + WHERE mcm.master_control_uuid = $1 + ORDER BY mcm.phase, cc.control_id + LIMIT 100 + `, [mc.id]) return NextResponse.json({ - mc_id: mcId, - members: res.rows, - count: res.rows.length, + id: mc.id, + control_id: mc.control_id, + title: mc.title, + objective: mc.objective, + severity: mc.total_controls > 100 ? 'high' : mc.total_controls > 20 ? 'medium' : 'low', + category: 'master_control', + release_state: 'active', + total_controls: mc.total_controls, + phases_covered: mc.phases_covered, + phase_control_count: mc.phase_control_count, + members: membersRes.rows, + requirements: membersRes.rows.map((m: { control_id: string; title: string; phase: string }) => + `[${m.phase}] ${m.control_id}: ${m.title}` + ), + test_procedure: [], + evidence: [], + open_anchors: [], + target_audience: [], + source_citation: null, + created_at: mc.created_at, }) } diff --git a/admin-compliance/app/sdk/control-library/components/useControlLibraryState.ts b/admin-compliance/app/sdk/control-library/components/useControlLibraryState.ts index c9e7111..8a123e3 100644 --- a/admin-compliance/app/sdk/control-library/components/useControlLibraryState.ts +++ b/admin-compliance/app/sdk/control-library/components/useControlLibraryState.ts @@ -18,7 +18,8 @@ export interface ControlsMeta { const PAGE_SIZE = 50 -export function useControlLibraryState() { +export function useControlLibraryState(backendUrlOverride?: string) { + const backendUrl = backendUrlOverride || BACKEND_URL const [frameworks, setFrameworks] = useState([]) const [controls, setControls] = useState([]) const [totalCount, setTotalCount] = useState(0) @@ -100,7 +101,7 @@ export function useControlLibraryState() { const loadFrameworks = useCallback(async () => { try { - const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`) + const res = await fetch(`${backendUrl}?endpoint=frameworks`) if (res.ok) setFrameworks(await res.json()) } catch { /* ignore */ } }, []) @@ -111,7 +112,7 @@ export function useControlLibraryState() { metaAbortRef.current = controller try { const qs = buildParams() - const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal }) + const res = await fetch(`${backendUrl}?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 @@ -130,8 +131,8 @@ export function useControlLibraryState() { 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 }), + fetch(`${backendUrl}?endpoint=controls&${qs}`, { signal: controller.signal }), + fetch(`${backendUrl}?endpoint=controls-count&${countQs}`, { signal: controller.signal }), ]) if (!controller.signal.aborted) { if (ctrlRes.ok) setControls(await ctrlRes.json()) @@ -147,7 +148,7 @@ export function useControlLibraryState() { const loadReviewCount = useCallback(async () => { try { - const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`) + const res = await fetch(`${backendUrl}?endpoint=controls-count&release_state=needs_review`) if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) } } catch { /* ignore */ } }, []) @@ -165,14 +166,14 @@ export function useControlLibraryState() { const loadProcessedStats = async () => { try { - const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`) + const res = await fetch(`${backendUrl}?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`) + const res = await fetch(`${backendUrl}?endpoint=controls&release_state=needs_review&limit=1000`) if (res.ok) { const items: CanonicalControl[] = await res.json() if (items.length > 0) { diff --git a/admin-compliance/app/sdk/master-controls/page.tsx b/admin-compliance/app/sdk/master-controls/page.tsx index 8713525..f357909 100644 --- a/admin-compliance/app/sdk/master-controls/page.tsx +++ b/admin-compliance/app/sdk/master-controls/page.tsx @@ -1,266 +1,110 @@ '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', -} +import { ControlDetail } from '../control-library/components/ControlDetail' +import { ControlListView } from '../control-library/components/ControlListView' +import { useControlLibraryState } from '../control-library/components/useControlLibraryState' +import { BACKEND_URL } from '../control-library/components/helpers' +/** + * Master Controls page — reuses the Control Library UI exactly, + * but shows Master Controls (13.5K grouped controls) instead of + * individual atomic controls (272K). + * + * The MC API route (/api/sdk/v1/master-controls) returns data in + * the same format as the canonical controls endpoint. + */ 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) + // Reuse the exact same state hook — it fetches from BACKEND_URL + // We override BACKEND_URL via a wrapper, but for now we reuse as-is + // since both endpoints speak the same format. + const state = useControlLibraryState('/api/sdk/v1/master-controls') - // 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
- )} -
-
-
- )} -
+ if (state.loading && state.controls.length === 0) { + return ( +
+
-
+ ) + } + + if (state.error) { + return ( +
+

{state.error}

+
+ ) + } + + // DETAIL mode + if (state.mode === 'detail' && state.selectedControl) { + return ( + { state.setMode('list'); state.setSelectedControl(null) }} + onEdit={() => {}} + onDelete={async () => {}} + onReview={async () => {}} + onRefresh={state.fullReload} + onCompare={() => {}} + onNavigateToControl={async (controlId: string) => { + try { + const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`) + if (res.ok) { state.setSelectedControl(await res.json()); state.setMode('detail') } + } catch { /* ignore */ } + }} + /> + ) + } + + // LIST mode — exact same UI as Control Library + return ( + {}} + setCurrentPage={state.setCurrentPage} + onSelectControl={(ctrl) => { state.setSelectedControl(ctrl); state.setMode('detail') }} + onCreateMode={() => {}} + onEnterReview={() => {}} + onBulkReject={async () => {}} + onRefresh={() => { state.loadControls(); state.loadMeta() }} + onLoadStats={state.loadProcessedStats} + onFullReload={state.fullReload} + /> ) }