diff --git a/admin-compliance/app/api/sdk/v1/einwilligungen/export/route.ts b/admin-compliance/app/api/sdk/v1/einwilligungen/export/route.ts new file mode 100644 index 00000000..d1574502 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/einwilligungen/export/route.ts @@ -0,0 +1,55 @@ +/** + * Proxy: GET /api/sdk/v1/einwilligungen/export?format=csv|json&kind=consents|history + * -> backend /api/compliance/einwilligungen/export/ + * + * Streams the backend response straight through (CSV or JSON download). + */ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002' + +function getTenantHeader(request: NextRequest): HeadersInit { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + const clientTenantId = request.headers.get('x-tenant-id') || request.headers.get('X-Tenant-ID') + const tenantId = (clientTenantId && uuidRegex.test(clientTenantId)) + ? clientTenantId + : (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e') + return { 'X-Tenant-ID': tenantId } +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const fmt = (searchParams.get('format') || 'csv').toLowerCase() + const kind = (searchParams.get('kind') || 'consents').toLowerCase() + + const filename = `${kind}.${fmt === 'json' ? 'json' : 'csv'}` + const upstreamPath = `/api/compliance/einwilligungen/export/${filename}` + + const passthroughParams = new URLSearchParams() + for (const k of ['user_id', 'granted', 'since', 'consent_id']) { + const v = searchParams.get(k) + if (v) passthroughParams.set(k, v) + } + const qs = passthroughParams.toString() + const url = `${BACKEND_URL}${upstreamPath}${qs ? `?${qs}` : ''}` + + try { + const r = await fetch(url, { headers: getTenantHeader(request) }) + if (!r.ok) { + const text = await r.text() + return NextResponse.json({ error: text || `HTTP ${r.status}` }, { status: r.status }) + } + return new NextResponse(r.body, { + status: 200, + headers: { + 'Content-Type': r.headers.get('content-type') || 'application/octet-stream', + 'Content-Disposition': r.headers.get('content-disposition') || `attachment; filename=${filename}`, + }, + }) + } catch (e) { + return NextResponse.json( + { error: 'Export-Proxy fehlgeschlagen', detail: String(e) }, + { status: 503 }, + ) + } +} diff --git a/admin-compliance/app/sdk/control-library/_components/ControlDetailView.tsx b/admin-compliance/app/sdk/control-library/_components/ControlDetailView.tsx index 28e924e9..86a5ddc5 100644 --- a/admin-compliance/app/sdk/control-library/_components/ControlDetailView.tsx +++ b/admin-compliance/app/sdk/control-library/_components/ControlDetailView.tsx @@ -8,6 +8,23 @@ import type { CanonicalControl } from '../_types' import { EFFORT_LABELS } from '../_types' import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges' +// Defensive coercers: backend has rows where evidence/requirements/test_procedure/open_anchors +// are JSON-encoded strings instead of arrays. .map() on a string throws — coerce here. +function asArray(v: unknown): T[] { + if (Array.isArray(v)) return v as T[] + if (typeof v === 'string' && v.trim().startsWith('[')) { + try { const p = JSON.parse(v); return Array.isArray(p) ? p : [] } catch { return [] } + } + return [] +} +function asStringArray(v: unknown): string[] { + return asArray(v).map(x => typeof x === 'string' ? x : JSON.stringify(x)) +} +type EvidenceItem = string | { type?: string; description?: string } +function asEvidenceArray(v: unknown): EvidenceItem[] { + return asArray(v) +} + export function ControlDetailView({ ctrl, onBack, @@ -72,31 +89,31 @@ export function ControlDetailView({

Geltungsbereich

- {ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && ( + {asStringArray(ctrl.scope?.platforms).length > 0 && (

Plattformen

- {ctrl.scope.platforms.map(p => ( + {asStringArray(ctrl.scope?.platforms).map(p => ( {p} ))}
)} - {ctrl.scope.components && ctrl.scope.components.length > 0 && ( + {asStringArray(ctrl.scope?.components).length > 0 && (

Komponenten

- {ctrl.scope.components.map(c => ( + {asStringArray(ctrl.scope?.components).map(c => ( {c} ))}
)} - {ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && ( + {asStringArray(ctrl.scope?.data_classes).length > 0 && (

Datenklassen

- {ctrl.scope.data_classes.map(d => ( + {asStringArray(ctrl.scope?.data_classes).map(d => ( {d} ))}
@@ -109,7 +126,7 @@ export function ControlDetailView({

Anforderungen

    - {ctrl.requirements.map((req, i) => ( + {asStringArray(ctrl.requirements).map((req, i) => (
  1. {i + 1} {req} @@ -122,7 +139,7 @@ export function ControlDetailView({

    Pruefverfahren

      - {ctrl.test_procedure.map((step, i) => ( + {asStringArray(ctrl.test_procedure).map((step, i) => (
    1. {step} @@ -135,12 +152,18 @@ export function ControlDetailView({

      Nachweisanforderungen

      - {ctrl.evidence.map((ev, i) => ( + {asEvidenceArray(ctrl.evidence).map((ev, i) => (
      - {ev.type} -

      {ev.description}

      + {typeof ev === 'string' ? ( +

      {ev}

      + ) : ( + <> + {ev.type && {ev.type}} +

      {ev.description ?? JSON.stringify(ev)}

      + + )}
      ))} @@ -152,13 +175,13 @@ export function ControlDetailView({

      Open-Source-Referenzen

      - ({ctrl.open_anchors.length} Quellen) + ({asArray(ctrl.open_anchors).length} Quellen)

      Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.

      - {ctrl.open_anchors.map((anchor, i) => ( + {asArray<{ framework?: string; ref?: string; url?: string }>(ctrl.open_anchors).map((anchor, i) => (
      @@ -180,11 +203,11 @@ export function ControlDetailView({
      {/* Tags */} - {ctrl.tags.length > 0 && ( + {asStringArray(ctrl.tags).length > 0 && (

      Tags

      - {ctrl.tags.map(tag => ( + {asStringArray(ctrl.tags).map(tag => ( {tag} ))}
      diff --git a/admin-compliance/app/sdk/control-library/components/ControlDetail.tsx b/admin-compliance/app/sdk/control-library/components/ControlDetail.tsx index 731d2e13..9ef6806f 100644 --- a/admin-compliance/app/sdk/control-library/components/ControlDetail.tsx +++ b/admin-compliance/app/sdk/control-library/components/ControlDetail.tsx @@ -18,6 +18,16 @@ import { ControlRegulatorySection } from './ControlRegulatorySection' import { ControlSimilarControls } from './ControlSimilarControls' import { ControlReviewActions } from './ControlReviewActions' +// Defensive coercer: some canonical_controls rows have evidence/tags/etc. +// as JSON-encoded strings instead of arrays. .map() on a string throws. +function toArray(v: unknown): T[] { + if (Array.isArray(v)) return v as T[] + if (typeof v === 'string' && v.trim().startsWith('[')) { + try { const p = JSON.parse(v); return Array.isArray(p) ? p : [] } catch { return [] } + } + return [] +} + interface SimilarControl { control_id: string; title: string; severity: string; release_state: string; tags: string[]; license_rule: number | null; verification_method: string | null; @@ -186,7 +196,7 @@ export function ControlDetail({ - {!ctrl.source_citation && ctrl.open_anchors.length > 0 && ( + {!ctrl.source_citation && toArray(ctrl.open_anchors).length > 0 && (
      @@ -201,36 +211,36 @@ export function ControlDetail({
      )} - {(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? ( + {(toArray(ctrl.scope?.platforms).length || toArray(ctrl.scope?.components).length || toArray(ctrl.scope?.data_classes).length) ? (

      Geltungsbereich

      - {ctrl.scope.platforms?.length ?
      Plattformen: {ctrl.scope.platforms.join(', ')}
      : null} - {ctrl.scope.components?.length ?
      Komponenten: {ctrl.scope.components.join(', ')}
      : null} - {ctrl.scope.data_classes?.length ?
      Datenklassen: {ctrl.scope.data_classes.join(', ')}
      : null} + {toArray(ctrl.scope?.platforms).length ?
      Plattformen: {toArray(ctrl.scope?.platforms).join(', ')}
      : null} + {toArray(ctrl.scope?.components).length ?
      Komponenten: {toArray(ctrl.scope?.components).join(', ')}
      : null} + {toArray(ctrl.scope?.data_classes).length ?
      Datenklassen: {toArray(ctrl.scope?.data_classes).join(', ')}
      : null}
      ) : null} - {Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && ( + {toArray(ctrl.requirements).length > 0 && (

      Anforderungen

      -
        {ctrl.requirements.map((r, i) =>
      1. {r}
      2. )}
      +
        {toArray(ctrl.requirements).map((r, i) =>
      1. {r}
      2. )}
      )} - {Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && ( + {toArray(ctrl.test_procedure).length > 0 && (

      Pruefverfahren

      -
        {ctrl.test_procedure.map((s, i) =>
      1. {s}
      2. )}
      +
        {toArray(ctrl.test_procedure).map((s, i) =>
      1. {s}
      2. )}
      )} - {ctrl.evidence.length > 0 && ( + {toArray(ctrl.evidence).length > 0 && (

      Nachweise

      - {ctrl.evidence.map((ev, i) => ( + {toArray(ctrl.evidence).map((ev, i) => (
      {typeof ev === 'string' ?
      {ev}
      :
      {ev.type}: {ev.description}
      } @@ -243,9 +253,9 @@ export function ControlDetail({
      {ctrl.risk_score !== null &&
      Risiko-Score: {ctrl.risk_score}
      } {ctrl.implementation_effort &&
      Aufwand: {EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}
      } - {ctrl.tags.length > 0 && ( + {toArray(ctrl.tags).length > 0 && (
      - {ctrl.tags.map(t => {t})} + {toArray(ctrl.tags).map(t => {t})}
      )}
      @@ -253,11 +263,11 @@ export function ControlDetail({
      -

      Open-Source-Referenzen ({ctrl.open_anchors.length})

      +

      Open-Source-Referenzen ({toArray(ctrl.open_anchors).length})

      - {ctrl.open_anchors.length > 0 ? ( + {toArray(ctrl.open_anchors).length > 0 ? (
      - {ctrl.open_anchors.map((anchor, i) => ( + {toArray<{ framework?: string; ref?: string; url?: string }>(ctrl.open_anchors).map((anchor, i) => (
      {anchor.framework} diff --git a/admin-compliance/app/sdk/control-library/page.tsx b/admin-compliance/app/sdk/control-library/page.tsx index 94adc36f..cdc7bd4c 100644 --- a/admin-compliance/app/sdk/control-library/page.tsx +++ b/admin-compliance/app/sdk/control-library/page.tsx @@ -1,5 +1,7 @@ 'use client' +import { useEffect } from 'react' +import { useSearchParams } from 'next/navigation' import { EMPTY_CONTROL } from './components/helpers' import { ControlForm } from './components/ControlForm' import { ControlDetail } from './components/ControlDetail' @@ -12,6 +14,24 @@ import { BACKEND_URL } from './components/helpers' export default function ControlLibraryPage() { const state = useControlLibraryState() + const searchParams = useSearchParams() + + // Deep-link via /sdk/control-library?control= + // — e.g. from /sdk/master-controls member list. + useEffect(() => { + const cid = searchParams?.get('control') + if (!cid || state.selectedControl?.control_id === cid) return + fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(cid)}`) + .then(r => r.ok ? r.json() : null) + .then(ctrl => { + if (ctrl?.control_id) { + state.setSelectedControl(ctrl) + state.setMode('detail') + } + }) + .catch(() => { /* user just sees the list */ }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]) const { handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject, diff --git a/admin-compliance/app/sdk/einwilligungen/page.tsx b/admin-compliance/app/sdk/einwilligungen/page.tsx index b4ae05e2..72cd65fc 100644 --- a/admin-compliance/app/sdk/einwilligungen/page.tsx +++ b/admin-compliance/app/sdk/einwilligungen/page.tsx @@ -57,12 +57,7 @@ export default function EinwilligungenPage() { explanation={stepInfo.explanation} tips={stepInfo.tips} > - + {/* Navigation Tabs */} @@ -150,3 +145,32 @@ export default function EinwilligungenPage() {
      ) } + +// Export-Dropdown im Step-Header. Streamt CSV/JSON direkt aus dem +// Backend via /api/sdk/v1/einwilligungen/export-Proxy. +function ConsentExportButton() { + return ( + + ) +} diff --git a/admin-compliance/app/sdk/master-controls/page.tsx b/admin-compliance/app/sdk/master-controls/page.tsx index 84bdf9c1..7b24aaf2 100644 --- a/admin-compliance/app/sdk/master-controls/page.tsx +++ b/admin-compliance/app/sdk/master-controls/page.tsx @@ -199,32 +199,43 @@ function MCDetail({ mc, onBack }: { mc: Record; onBack: () => v
      ) : (
      - {filtered.map((m, i) => ( -
      -
      - {m.control_id} - {m.severity && ( - - {m.severity} - + {filtered.map((m, i) => { + const inner = ( + <> +
      + {m.control_id} + {m.severity && ( + + {m.severity} + + )} + {m.phase && ( + + {m.phase} + + )} + {m.action && ( + {m.action} + )} +
      +

      {m.title}

      + {m.regulation_source && ( +

      + {m.regulation_source} {m.regulation_article} +

      )} - {m.phase && ( - - {m.phase} - - )} - {m.action && ( - {m.action} - )} -
      -

      {m.title}

      - {m.regulation_source && ( -

      - {m.regulation_source} {m.regulation_article} -

      - )} -
      - ))} + + ) + return m.control_id ? ( + + {inner} + + ) : ( +
      {inner}
      + ) + })} {filtered.length === 0 && !loading && (
      Keine Controls gefunden
      )} diff --git a/admin-compliance/lib/sdk/einwilligungen/generator/cookie-banner-content-blocker.ts b/admin-compliance/lib/sdk/einwilligungen/generator/cookie-banner-content-blocker.ts new file mode 100644 index 00000000..42aa6b0d --- /dev/null +++ b/admin-compliance/lib/sdk/einwilligungen/generator/cookie-banner-content-blocker.ts @@ -0,0 +1,156 @@ +/** + * Content-Blocker Generator (Borlabs-Parity). + * + * Returns a small JS snippet that scans the page for blockable third-party + * embeds (YouTube, Vimeo, Google Maps, Spotify, Twitter, Facebook) and + * replaces them with a click-to-consent placeholder until the user agrees + * to the relevant cookie category. + * + * The customer drops a SECOND script tag next to the banner: + * + * + * + * Author writes content as either: + * + * + * + * + * OR auto-detect: any