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) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const action = searchParams.get('action') || 'list'
|
const endpoint = searchParams.get('endpoint') || 'controls'
|
||||||
|
|
||||||
if (action === 'list') {
|
switch (endpoint) {
|
||||||
return handleList(searchParams)
|
case 'frameworks':
|
||||||
}
|
return NextResponse.json([])
|
||||||
if (action === 'detail') {
|
|
||||||
return handleDetail(searchParams)
|
|
||||||
}
|
|
||||||
if (action === 'members') {
|
|
||||||
return handleMembers(searchParams)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
} catch (e) {
|
||||||
return NextResponse.json({ error: String(e) }, { status: 500 })
|
return NextResponse.json({ error: String(e) }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleList(params: URLSearchParams) {
|
async function handleControls(params: URLSearchParams) {
|
||||||
const search = params.get('search') || ''
|
const search = params.get('search') || ''
|
||||||
const minControls = parseInt(params.get('min_controls') || '0')
|
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
|
||||||
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 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'
|
let where = "WHERE 1=1"
|
||||||
const validOrder = order === 'ASC' ? 'ASC' : 'DESC'
|
|
||||||
|
|
||||||
let where = 'WHERE 1=1'
|
|
||||||
const args: unknown[] = []
|
const args: unknown[] = []
|
||||||
let idx = 1
|
let idx = 1
|
||||||
|
|
||||||
@@ -49,81 +54,150 @@ async function handleList(params: URLSearchParams) {
|
|||||||
args.push(`%${search}%`)
|
args.push(`%${search}%`)
|
||||||
idx++
|
idx++
|
||||||
}
|
}
|
||||||
if (minControls > 0) {
|
|
||||||
where += ` AND mc.total_controls >= $${idx}`
|
|
||||||
args.push(minControls)
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
|
|
||||||
const countRes = await pool.query(
|
const sortCol = sort === 'control_id' ? 'mc.master_control_id' :
|
||||||
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
|
sort === 'created_at' ? 'mc.created_at' : 'mc.master_control_id'
|
||||||
)
|
|
||||||
const total = parseInt(countRes.rows[0].count)
|
|
||||||
|
|
||||||
args.push(limit, offset)
|
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(
|
const res = await pool.query(
|
||||||
`SELECT mc.id, mc.master_control_id, mc.canonical_name,
|
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
|
||||||
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: 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({
|
return NextResponse.json({
|
||||||
total,
|
total: parseInt(r.total),
|
||||||
limit,
|
severity_counts: {
|
||||||
offset,
|
high: parseInt(r.high_count),
|
||||||
master_controls: res.rows,
|
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) {
|
async function handleDetail(params: URLSearchParams) {
|
||||||
const id = params.get('id')
|
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 as control_id, mc.canonical_name as title,
|
||||||
const res = await pool.query(
|
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
|
||||||
`SELECT mc.id, mc.master_control_id, mc.canonical_name,
|
mc.total_controls, mc.phases_covered, mc.phase_control_count, mc.created_at
|
||||||
mc.total_controls, mc.phases_covered, mc.phase_control_count
|
FROM compliance.master_controls mc
|
||||||
FROM compliance.master_controls mc
|
WHERE mc.master_control_id = $1 OR mc.id::text = $1
|
||||||
WHERE mc.master_control_id = $1 OR mc.id::text = $1`,
|
`, [id])
|
||||||
[id]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (res.rows.length === 0) {
|
if (res.rows.length === 0) {
|
||||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
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) {
|
// Load members
|
||||||
const mcId = params.get('mc_id')
|
const membersRes = await pool.query(`
|
||||||
if (!mcId) return NextResponse.json({ error: 'mc_id required' }, { status: 400 })
|
SELECT cc.control_id, cc.title, cc.severity, mcm.phase, mcm.action
|
||||||
|
FROM compliance.master_control_members mcm
|
||||||
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
|
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
|
||||||
const offset = parseInt(params.get('offset') || '0')
|
WHERE mcm.master_control_uuid = $1
|
||||||
|
ORDER BY mcm.phase, cc.control_id
|
||||||
const res = await pool.query(
|
LIMIT 100
|
||||||
`SELECT cc.control_id, cc.title, cc.objective, cc.severity,
|
`, [mc.id])
|
||||||
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({
|
return NextResponse.json({
|
||||||
mc_id: mcId,
|
id: mc.id,
|
||||||
members: res.rows,
|
control_id: mc.control_id,
|
||||||
count: res.rows.length,
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export interface ControlsMeta {
|
|||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
export function useControlLibraryState() {
|
export function useControlLibraryState(backendUrlOverride?: string) {
|
||||||
|
const backendUrl = backendUrlOverride || BACKEND_URL
|
||||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||||
const [totalCount, setTotalCount] = useState(0)
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
@@ -100,7 +101,7 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadFrameworks = useCallback(async () => {
|
const loadFrameworks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
const res = await fetch(`${backendUrl}?endpoint=frameworks`)
|
||||||
if (res.ok) setFrameworks(await res.json())
|
if (res.ok) setFrameworks(await res.json())
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [])
|
}, [])
|
||||||
@@ -111,7 +112,7 @@ export function useControlLibraryState() {
|
|||||||
metaAbortRef.current = controller
|
metaAbortRef.current = controller
|
||||||
try {
|
try {
|
||||||
const qs = buildParams()
|
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())
|
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
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 qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
||||||
const countQs = buildParams()
|
const countQs = buildParams()
|
||||||
const [ctrlRes, countRes] = await Promise.all([
|
const [ctrlRes, countRes] = await Promise.all([
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
fetch(`${backendUrl}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
fetch(`${backendUrl}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||||
])
|
])
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||||
@@ -147,7 +148,7 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadReviewCount = useCallback(async () => {
|
const loadReviewCount = useCallback(async () => {
|
||||||
try {
|
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) }
|
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [])
|
}, [])
|
||||||
@@ -165,14 +166,14 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadProcessedStats = async () => {
|
const loadProcessedStats = async () => {
|
||||||
try {
|
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 || []) }
|
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const enterReviewMode = async () => {
|
const enterReviewMode = async () => {
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const items: CanonicalControl[] = await res.json()
|
const items: CanonicalControl[] = await res.json()
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
|
|||||||
@@ -1,266 +1,110 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import { ControlDetail } from '../control-library/components/ControlDetail'
|
||||||
|
import { ControlListView } from '../control-library/components/ControlListView'
|
||||||
interface MC {
|
import { useControlLibraryState } from '../control-library/components/useControlLibraryState'
|
||||||
id: string
|
import { BACKEND_URL } from '../control-library/components/helpers'
|
||||||
master_control_id: string
|
|
||||||
canonical_name: string
|
|
||||||
total_controls: number
|
|
||||||
phases_covered: string[]
|
|
||||||
phase_control_count: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string> = {
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export default function MasterControlsPage() {
|
||||||
const [mcs, setMcs] = useState<MC[]>([])
|
// Reuse the exact same state hook — it fetches from BACKEND_URL
|
||||||
const [total, setTotal] = useState(0)
|
// We override BACKEND_URL via a wrapper, but for now we reuse as-is
|
||||||
const [offset, setOffset] = useState(0)
|
// since both endpoints speak the same format.
|
||||||
const [search, setSearch] = useState('')
|
const state = useControlLibraryState('/api/sdk/v1/master-controls')
|
||||||
const [sortBy, setSortBy] = useState('total_controls')
|
|
||||||
const [sortOrder, setSortOrder] = useState('DESC')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
// Detail view
|
if (state.loading && state.controls.length === 0) {
|
||||||
const [selectedMC, setSelectedMC] = useState<MC | null>(null)
|
return (
|
||||||
const [members, setMembers] = useState<Member[]>([])
|
<div className="flex items-center justify-center h-96">
|
||||||
const [membersLoading, setMembersLoading] = useState(false)
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
|
||||||
<div className="mb-6">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Master Controls</h1>
|
|
||||||
<p className="text-gray-600 mt-1">
|
|
||||||
{total.toLocaleString()} Master Controls mit {mcs.reduce((s, m) => s + m.total_controls, 0).toLocaleString()}+ Atomic Controls
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search + Filters */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Suche nach MC-Name (z.B. encryption, incident, access_control)..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => { 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"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
|
||||||
Seite {currentPage} von {totalPages}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
{/* MC List */}
|
|
||||||
<div className={`${selectedMC ? 'w-1/2' : 'w-full'} transition-all`}>
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-gray-50 border-b">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-purple-600"
|
|
||||||
onClick={() => handleSort('canonical_name')}>
|
|
||||||
Name {sortBy === 'canonical_name' ? (sortOrder === 'ASC' ? '↑' : '↓') : ''}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase cursor-pointer hover:text-purple-600"
|
|
||||||
onClick={() => handleSort('total_controls')}>
|
|
||||||
Controls {sortBy === 'total_controls' ? (sortOrder === 'ASC' ? '↑' : '↓') : ''}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Phasen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{loading ? (
|
|
||||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-400">Laden...</td></tr>
|
|
||||||
) : mcs.map(mc => {
|
|
||||||
const l1 = getL1Token(mc.canonical_name)
|
|
||||||
const l2 = mc.canonical_name.slice(l1.length + 1) || ''
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={mc.id}
|
|
||||||
onClick={() => loadMembers(mc)}
|
|
||||||
className={`cursor-pointer hover:bg-purple-50 transition-colors ${
|
|
||||||
selectedMC?.id === mc.id ? 'bg-purple-100' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="text-xs font-mono bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
|
|
||||||
{l1}
|
|
||||||
</span>
|
|
||||||
{l2 && (
|
|
||||||
<span className="ml-1.5 text-sm text-gray-700">{l2.replace(/_/g, ' ')}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900">
|
|
||||||
{mc.total_controls}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-sm text-gray-500">
|
|
||||||
{(mc.phases_covered || []).length}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
|
|
||||||
<button
|
|
||||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
|
||||||
disabled={offset === 0}
|
|
||||||
className="px-3 py-1 text-sm border rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Zurueck
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{offset + 1}-{Math.min(offset + PAGE_SIZE, total)} von {total}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
|
||||||
disabled={offset + PAGE_SIZE >= total}
|
|
||||||
className="px-3 py-1 text-sm border rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Weiter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Member Detail Panel */}
|
|
||||||
{selectedMC && (
|
|
||||||
<div className="w-1/2">
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden sticky top-4">
|
|
||||||
<div className="px-4 py-3 bg-purple-50 border-b flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="font-semibold text-purple-900">{selectedMC.canonical_name}</h2>
|
|
||||||
<p className="text-xs text-purple-600">{selectedMC.total_controls} Controls, {(selectedMC.phases_covered || []).length} Phasen</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setSelectedMC(null)} className="text-purple-400 hover:text-purple-600 text-lg">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[70vh] overflow-y-auto">
|
|
||||||
{membersLoading ? (
|
|
||||||
<div className="p-8 text-center text-gray-400">Laden...</div>
|
|
||||||
) : members.map((m, i) => (
|
|
||||||
<div key={i} className="px-4 py-3 border-b border-gray-50 hover:bg-gray-50">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-xs font-mono text-gray-400">{m.control_id}</span>
|
|
||||||
{m.severity && (
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV_COLORS[m.severity] || ''}`}>
|
|
||||||
{m.severity}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">{m.phase}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-900">{m.title}</p>
|
|
||||||
{m.regulation_source && (
|
|
||||||
<p className="text-xs text-blue-600 mt-1">{m.regulation_source} {m.regulation_article}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{members.length === 0 && !membersLoading && (
|
|
||||||
<div className="p-8 text-center text-gray-400">Keine Members gefunden</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<p className="text-red-600">{state.error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DETAIL mode
|
||||||
|
if (state.mode === 'detail' && state.selectedControl) {
|
||||||
|
return (
|
||||||
|
<ControlDetail
|
||||||
|
ctrl={state.selectedControl}
|
||||||
|
onBack={() => { 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 (
|
||||||
|
<ControlListView
|
||||||
|
frameworks={state.frameworks}
|
||||||
|
controls={state.controls}
|
||||||
|
totalCount={state.totalCount}
|
||||||
|
meta={state.meta}
|
||||||
|
loading={state.loading}
|
||||||
|
reviewCount={0}
|
||||||
|
bulkProcessing={false}
|
||||||
|
showStats={state.showStats}
|
||||||
|
processedStats={state.processedStats}
|
||||||
|
showGenerator={false}
|
||||||
|
currentPage={state.currentPage}
|
||||||
|
totalPages={state.totalPages}
|
||||||
|
sortBy={state.sortBy}
|
||||||
|
searchQuery={state.searchQuery}
|
||||||
|
severityFilter={state.severityFilter}
|
||||||
|
domainFilter={state.domainFilter}
|
||||||
|
stateFilter={state.stateFilter}
|
||||||
|
verificationFilter={state.verificationFilter}
|
||||||
|
categoryFilter={state.categoryFilter}
|
||||||
|
evidenceTypeFilter={state.evidenceTypeFilter}
|
||||||
|
audienceFilter={state.audienceFilter}
|
||||||
|
sourceFilter={state.sourceFilter}
|
||||||
|
typeFilter={state.typeFilter}
|
||||||
|
hideDuplicates={state.hideDuplicates}
|
||||||
|
setSearchQuery={state.setSearchQuery}
|
||||||
|
setSeverityFilter={state.setSeverityFilter}
|
||||||
|
setDomainFilter={state.setDomainFilter}
|
||||||
|
setStateFilter={state.setStateFilter}
|
||||||
|
setVerificationFilter={state.setVerificationFilter}
|
||||||
|
setCategoryFilter={state.setCategoryFilter}
|
||||||
|
setEvidenceTypeFilter={state.setEvidenceTypeFilter}
|
||||||
|
setAudienceFilter={state.setAudienceFilter}
|
||||||
|
setSourceFilter={state.setSourceFilter}
|
||||||
|
setTypeFilter={state.setTypeFilter}
|
||||||
|
setHideDuplicates={state.setHideDuplicates}
|
||||||
|
setSortBy={state.setSortBy}
|
||||||
|
setShowStats={state.setShowStats}
|
||||||
|
setShowGenerator={() => {}}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user