import { useEffect, useState } from 'react'; import { Card } from '../components/Card'; import { Badge } from '../components/Badge'; import { wpApiFetch, useConfig } from '../hooks/useApi'; import { useNotify } from '../hooks/useNotify'; import { DOCUMENT_TEMPLATES } from '@cra-compliance/shared'; import type { DocumentTemplate, DocumentField } from '@cra-compliance/types'; interface GeneratedDocument { id: number; template_id: string; title: string; content: string; field_values: string; created_at: string; } export function Documents() { const config = useConfig(); const isPro = config.plan === 'pro'; const { confirm, toast, NotifyPortal } = useNotify(); const [savedDocs, setSavedDocs] = useState([]); const [activeTemplate, setActiveTemplate] = useState(null); const [fieldValues, setFieldValues] = useState>({}); const [generatedContent, setGeneratedContent] = useState(null); const [errors, setErrors] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); useEffect(() => { wpApiFetch<{ data: GeneratedDocument[] }>('documents') .then((res) => setSavedDocs(res.data || [])) .catch(() => {}) .finally(() => setLoading(false)); }, []); const selectTemplate = (template: DocumentTemplate) => { setActiveTemplate(template); setGeneratedContent(null); setErrors({}); // Pre-fill with default values const defaults: Record = {}; template.fields.forEach((f) => { defaults[f.id] = ''; }); defaults['current_date'] = new Date().toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric', }); setFieldValues(defaults); }; const validateFields = (): boolean => { if (!activeTemplate) return false; const newErrors: Record = {}; for (const field of activeTemplate.fields) { const value = (fieldValues[field.id] || '').trim(); if (field.required && !value) { newErrors[field.id] = `${field.label} is required`; continue; } if (!value) continue; if (field.type === 'email') { const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailPattern.test(value)) { newErrors[field.id] = 'Please enter a valid email address'; } } if (field.type === 'url') { try { const url = new URL(value); if (!url.protocol.startsWith('http')) { newErrors[field.id] = 'URL must start with http:// or https://'; } } catch { newErrors[field.id] = 'Please enter a valid URL (e.g. https://example.com)'; } } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const buildContent = (values: Record) => { if (!activeTemplate) return ''; let content = activeTemplate.template_content; Object.entries(values).forEach(([key, value]) => { const label = activeTemplate.fields.find((f) => f.id === key)?.label || key; content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value || `[${label}]`); }); // Replace any remaining unreplaced variables (e.g. current_date) content = content.replace(/\{\{(\w+)\}\}/g, (_, k) => values[k] || `[${k}]`); return content; }; const liveContent = activeTemplate ? buildContent(fieldValues) : ''; const generateDocument = () => { if (!activeTemplate) return; if (!validateFields()) return; setGeneratedContent(liveContent); }; const saveDocument = async () => { if (!activeTemplate || !generatedContent) return; setSaving(true); try { const res = await wpApiFetch<{ data: GeneratedDocument }>('documents', { method: 'POST', body: JSON.stringify({ template_id: activeTemplate.id, title: activeTemplate.title, content: generatedContent, field_values: JSON.stringify(fieldValues), }), }); setSavedDocs((prev) => [res.data, ...prev]); setActiveTemplate(null); setGeneratedContent(null); toast({ message: 'Document saved.', type: 'success' }); } catch { toast({ message: 'Failed to save document.', type: 'error' }); } finally { setSaving(false); } }; const deleteDocument = async (id: number) => { const ok = await confirm({ message: 'Delete this document? This cannot be undone.', danger: true, confirmLabel: 'Delete' }); if (!ok) return; try { await wpApiFetch(`documents/${id}`, { method: 'DELETE' }); setSavedDocs((prev) => prev.filter((d) => d.id !== id)); toast({ message: 'Document deleted.', type: 'success' }); } catch { toast({ message: 'Failed to delete document.', type: 'error' }); } }; const copyToClipboard = (content: string) => { navigator.clipboard.writeText(content); }; const downloadAsFile = (content: string, title: string, ext: 'txt' | 'md' = 'txt') => { const mimeType = ext === 'md' ? 'text/markdown' : 'text/plain'; const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${title.replace(/\s+/g, '-').toLowerCase()}.${ext}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const downloadAsCycloneDX = () => { if (!activeTemplate || activeTemplate.id !== 'sbom') return; const v = fieldValues; const now = new Date().toISOString(); const slug = (v['plugin_slug'] || v['plugin_name'] || 'plugin').toLowerCase().replace(/\s+/g, '-'); const phpDeps = (v['php_dependencies'] || '').split(/[\n,]+/).map(s => s.trim()).filter(Boolean); const jsDeps = (v['js_dependencies'] || '').split(/[\n,]+/).map(s => s.trim()).filter(Boolean); const toComponent = (raw: string, type: string) => { const match = raw.match(/^(.+?)\s+([\^~>=<\d].*)$/); const name = match ? match[1].trim() : raw; const version = match ? match[2].replace(/[\^~>=<]/g, '').trim() : 'unknown'; return { type: 'library', 'bom-ref': `${name}@${version}`, name, version, purl: `pkg:${type}/${name}@${version}`, }; }; const cyclonedx = { bomFormat: 'CycloneDX', specVersion: '1.5', serialNumber: `urn:uuid:${('10000000-1000-4000-8000-100000000000').replace(/[018]/g, (c) => (parseInt(c) ^ (Math.random() * 16 >> parseInt(c) / 4)).toString(16))}`, version: 1, metadata: { timestamp: now, tools: [{ vendor: 'ResilienceWP', name: 'CRA Compliance Manager', version: '1.2.1' }], component: { type: 'application', 'bom-ref': `${slug}@${v['plugin_version'] || '0.0.0'}`, name: v['plugin_name'] || '', version: v['plugin_version'] || '', purl: `pkg:wordpress-plugin/${slug}@${v['plugin_version'] || '0.0.0'}`, licenses: [{ license: { id: v['license'] || 'GPL-2.0-or-later' } }], supplier: { name: v['org_name'] || '' }, }, }, components: [ ...phpDeps.map(d => toComponent(d, 'composer')), ...jsDeps.map(d => toComponent(d, 'npm')), ], }; const blob = new Blob([JSON.stringify(cyclonedx, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `sbom-${slug}-${v['plugin_version'] || '0.0.0'}.cdx.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; if (loading) { return
Loading documents...
; } // Template selection view if (!activeTemplate) { return (

Document Generator

Generate CRA-compliant documents required for EU Cyber Resilience Act conformity. Fill in your details and download.

Available Templates

{DOCUMENT_TEMPLATES.map((template) => { const locked = template.premium_only && !isPro; return locked ? (
Pro

{template.title}

{template.description}

{template.cra_articles.map((article) => ( {article} ))}
) : ( ); })}
{savedDocs.length > 0 && (

Your Generated Documents

{savedDocs.map((doc) => (
{doc.title} {new Date(doc.created_at).toLocaleDateString()}
))}
)}
); } // Document generation view return (

{activeTemplate.title}

{activeTemplate.description}

{/* Form */}
{activeTemplate.fields.map((field) => (
{renderField(field, fieldValues[field.id] || '', (value) => { setFieldValues((prev) => ({ ...prev, [field.id]: value })); setGeneratedContent(null); if (errors[field.id]) { setErrors((prev) => { const next = { ...prev }; delete next[field.id]; return next; }); } }, errors[field.id])} {errors[field.id] && ( {errors[field.id]} )}
))}
{activeTemplate.id === 'security-txt' ? ( ) : ( <> )} {activeTemplate.id === 'sbom' && ( )}
{/* Live Preview - always visible */}
) : undefined } >
{generatedContent || liveContent}
); } function renderField( field: DocumentField, value: string, onChange: (value: string) => void, error?: string ) { const cls = `cra-input${error ? ' cra-input-error' : ''}`; if (field.type === 'textarea') { return (