refactor(sdk): split hooks, dsr-portal, provider, sync approaching 500 LOC

All four files split into focused sibling modules so every file lands
comfortably under the 300-LOC soft target (hard cap 500):

  hooks.ts (474→43)  → hooks-core / hooks-dsgvo / hooks-compliance
                        hooks-rag-security / hooks-ui
  dsr-portal.ts (464→129) → dsr-portal-translations / dsr-portal-render
  provider.tsx (462→247)  → provider-effects / provider-callbacks
  sync.ts (435→299)       → sync-storage / sync-conflict

Zero behaviour changes. All public APIs remain importable from the
original paths (hooks.ts re-exports every hook, provider.tsx keeps all
named exports, sync.ts preserves StateSyncManager + factory).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-18 08:40:20 +02:00
parent 19d6437161
commit 9ecd3b2d84
15 changed files with 1700 additions and 1299 deletions

View File

@@ -0,0 +1,67 @@
/**
* Conflict resolution helpers for StateSyncManager.
*
* Extracted from sync.ts to stay within the 300-LOC target.
*/
import type { SDKState, ConflictResolution } from '@breakpilot/compliance-sdk-types'
// =============================================================================
// DEFAULT CONFLICT HANDLER
// =============================================================================
/**
* Default strategy: if local is newer, keep local; otherwise merge
* server as the base but preserve local preferences and deduplicate
* commandBarHistory / recentSearches.
*/
export async function defaultConflictHandler(
local: SDKState,
server: SDKState
): Promise<ConflictResolution> {
const localTime = new Date(local.lastModified).getTime()
const serverTime = new Date(server.lastModified).getTime()
if (localTime > serverTime) {
return { strategy: 'local' }
}
const mergedState: SDKState = {
...server,
preferences: local.preferences,
commandBarHistory: [
...local.commandBarHistory,
...server.commandBarHistory.filter(
h => !local.commandBarHistory.some(lh => lh.id === h.id)
),
].slice(0, 50),
recentSearches: [...new Set([...local.recentSearches, ...server.recentSearches])].slice(
0,
20
),
}
return { strategy: 'merge', mergedState }
}
// =============================================================================
// CONFLICT RESOLUTION APPLIER
// =============================================================================
/**
* Given a resolution strategy and both states, returns the winning state.
*/
export function applyConflictResolution(
resolution: ConflictResolution,
localState: SDKState,
serverState: SDKState
): SDKState {
switch (resolution.strategy) {
case 'local':
return localState
case 'server':
return serverState
case 'merge':
return resolution.mergedState || localState
}
}

View File

@@ -0,0 +1,154 @@
/**
* Local-storage and BroadcastChannel helpers for StateSyncManager.
*
* Extracted from sync.ts to stay within the 300-LOC target.
*/
import type { SDKState, SyncState } from '@breakpilot/compliance-sdk-types'
// =============================================================================
// CONSTANTS
// =============================================================================
export const STORAGE_KEY_PREFIX = 'breakpilot-compliance-sdk-state'
export const SYNC_CHANNEL = 'breakpilot-sdk-state-sync'
// =============================================================================
// TAB ID
// =============================================================================
export function getTabId(): string {
if (typeof window === 'undefined') return 'server'
let tabId = sessionStorage.getItem('breakpilot-sdk-tab-id')
if (!tabId) {
tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
sessionStorage.setItem('breakpilot-sdk-tab-id', tabId)
}
return tabId
}
// =============================================================================
// LOCAL STORAGE HELPERS
// =============================================================================
export function getStorageKey(tenantId: string): string {
return `${STORAGE_KEY_PREFIX}-${tenantId}`
}
export function saveStateToLocalStorage(
tenantId: string,
state: SDKState,
version: number
): void {
if (typeof window === 'undefined') return
try {
const data = {
state,
version,
savedAt: new Date().toISOString(),
}
localStorage.setItem(getStorageKey(tenantId), JSON.stringify(data))
} catch (error) {
console.error('Failed to save to localStorage:', error)
}
}
export function loadStateFromLocalStorage(
tenantId: string,
syncState: SyncState
): SDKState | null {
if (typeof window === 'undefined') return null
try {
const stored = localStorage.getItem(getStorageKey(tenantId))
if (stored) {
const data = JSON.parse(stored)
syncState.localVersion = data.version || 0
return data.state
}
} catch (error) {
console.error('Failed to load from localStorage:', error)
}
return null
}
export function clearStateFromLocalStorage(tenantId: string): void {
if (typeof window === 'undefined') return
try {
localStorage.removeItem(getStorageKey(tenantId))
} catch (error) {
console.error('Failed to clear localStorage:', error)
}
}
// =============================================================================
// BROADCAST CHANNEL HELPERS
// =============================================================================
export function createBroadcastChannel(
tenantId: string
): BroadcastChannel | null {
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
return null
}
try {
return new BroadcastChannel(`${SYNC_CHANNEL}-${tenantId}`)
} catch (error) {
console.warn('BroadcastChannel not available:', error)
return null
}
}
export function broadcastStateUpdate(
channel: BroadcastChannel | null,
state: SDKState,
version: number
): void {
if (!channel) return
channel.postMessage({
type: 'STATE_UPDATED',
state,
version,
tabId: getTabId(),
})
}
export function broadcastSyncComplete(
channel: BroadcastChannel | null,
version: number
): void {
if (!channel) return
channel.postMessage({
type: 'SYNC_COMPLETE',
version,
tabId: getTabId(),
})
}
// =============================================================================
// ONLINE / OFFLINE LISTENER
// =============================================================================
export interface OnlineListenerHandlers {
onOnline: () => void
onOffline: () => void
}
/**
* Registers window online/offline listeners and returns the current online status.
* No-ops in non-browser environments.
*/
export function setupOnlineListener(handlers: OnlineListenerHandlers): boolean {
if (typeof window === 'undefined') return true
window.addEventListener('online', handlers.onOnline)
window.addEventListener('offline', handlers.onOffline)
return navigator.onLine
}

View File

@@ -1,23 +1,26 @@
/**
* SDK State Synchronization
*
* Handles offline/online sync, multi-tab coordination,
* and conflict resolution for SDK state.
*/
// SDK State Synchronization — offline/online sync, multi-tab coordination.
// Split: localStorage+BroadcastChannel → sync-storage.ts | conflict → sync-conflict.ts
import type { SDKState, SyncState, SyncStatus, ConflictResolution } from '@breakpilot/compliance-sdk-types'
import { ComplianceClient } from './client'
import {
saveStateToLocalStorage,
loadStateFromLocalStorage,
clearStateFromLocalStorage,
createBroadcastChannel,
broadcastStateUpdate,
broadcastSyncComplete,
setupOnlineListener,
} from './sync-storage'
import { defaultConflictHandler, applyConflictResolution } from './sync-conflict'
// =============================================================================
// TYPES
// =============================================================================
// --- Types & Constants -------------------------------------------------------
export interface SyncOptions {
debounceMs?: number
maxRetries?: number
conflictHandler?: (local: SDKState, server: SDKState) => Promise<ConflictResolution>
}
export interface SyncCallbacks {
onSyncStart?: () => void
onSyncComplete?: (state: SDKState) => void
@@ -27,18 +30,10 @@ export interface SyncCallbacks {
onOnline?: () => void
}
// =============================================================================
// CONSTANTS
// =============================================================================
const STORAGE_KEY_PREFIX = 'breakpilot-compliance-sdk-state'
const SYNC_CHANNEL = 'breakpilot-sdk-state-sync'
const DEFAULT_DEBOUNCE_MS = 2000
const DEFAULT_MAX_RETRIES = 3
// =============================================================================
// STATE SYNC MANAGER
// =============================================================================
// --- StateSyncManager --------------------------------------------------------
export class StateSyncManager {
private client: ComplianceClient
@@ -63,7 +58,7 @@ export class StateSyncManager {
this.options = {
debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
conflictHandler: options.conflictHandler ?? this.defaultConflictHandler.bind(this),
conflictHandler: options.conflictHandler ?? defaultConflictHandler,
}
this.syncState = {
@@ -79,52 +74,33 @@ export class StateSyncManager {
this.setupOnlineListener()
}
// ---------------------------------------------------------------------------
// Setup Methods
// ---------------------------------------------------------------------------
// -- Setup --
private setupBroadcastChannel(): void {
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
return
}
try {
this.broadcastChannel = new BroadcastChannel(`${SYNC_CHANNEL}-${this.tenantId}`)
this.broadcastChannel = createBroadcastChannel(this.tenantId)
if (this.broadcastChannel) {
this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this)
} catch (error) {
console.warn('BroadcastChannel not available:', error)
}
}
private setupOnlineListener(): void {
if (typeof window === 'undefined') {
return
}
window.addEventListener('online', () => {
this.isOnline = true
this.syncState.status = 'idle'
this.callbacks.onOnline?.()
if (this.pendingState) {
this.syncToServer(this.pendingState)
}
this.isOnline = setupOnlineListener({
onOnline: () => {
this.isOnline = true
this.syncState.status = 'idle'
this.callbacks.onOnline?.()
if (this.pendingState) this.syncToServer(this.pendingState)
},
onOffline: () => {
this.isOnline = false
this.syncState.status = 'offline'
this.callbacks.onOffline?.()
},
})
window.addEventListener('offline', () => {
this.isOnline = false
this.syncState.status = 'offline'
this.callbacks.onOffline?.()
})
this.isOnline = navigator.onLine
if (!this.isOnline) {
this.syncState.status = 'offline'
}
if (!this.isOnline) this.syncState.status = 'offline'
}
// ---------------------------------------------------------------------------
// Broadcast Channel Methods
// ---------------------------------------------------------------------------
// -- Broadcast Channel --
private handleBroadcastMessage(event: MessageEvent): void {
const { type, state, version } = event.data
@@ -143,98 +119,33 @@ export class StateSyncManager {
break
case 'REQUEST_STATE':
this.broadcastState()
this.broadcastCurrentState()
break
}
}
private broadcastState(): void {
if (!this.broadcastChannel) return
private broadcastCurrentState(): void {
const state = this.loadFromLocalStorage()
if (state) {
this.broadcastChannel.postMessage({
type: 'STATE_UPDATED',
state,
version: this.syncState.localVersion,
tabId: this.getTabId(),
})
broadcastStateUpdate(this.broadcastChannel, state, this.syncState.localVersion)
}
}
private broadcastSyncComplete(version: number): void {
if (!this.broadcastChannel) return
this.broadcastChannel.postMessage({
type: 'SYNC_COMPLETE',
version,
tabId: this.getTabId(),
})
}
private getTabId(): string {
if (typeof window === 'undefined') return 'server'
let tabId = sessionStorage.getItem('breakpilot-sdk-tab-id')
if (!tabId) {
tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
sessionStorage.setItem('breakpilot-sdk-tab-id', tabId)
}
return tabId
}
// ---------------------------------------------------------------------------
// Local Storage Methods
// ---------------------------------------------------------------------------
private getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}-${this.tenantId}`
}
// -- Local Storage (sync-storage helpers) --
saveToLocalStorage(state: SDKState): void {
if (typeof window === 'undefined') return
try {
const data = {
state,
version: this.syncState.localVersion,
savedAt: new Date().toISOString(),
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(data))
} catch (error) {
console.error('Failed to save to localStorage:', error)
}
saveStateToLocalStorage(this.tenantId, state, this.syncState.localVersion)
}
loadFromLocalStorage(): SDKState | null {
if (typeof window === 'undefined') return null
try {
const stored = localStorage.getItem(this.getStorageKey())
if (stored) {
const data = JSON.parse(stored)
this.syncState.localVersion = data.version || 0
return data.state
}
} catch (error) {
console.error('Failed to load from localStorage:', error)
}
return null
return loadStateFromLocalStorage(this.tenantId, this.syncState)
}
clearLocalStorage(): void {
if (typeof window === 'undefined') return
try {
localStorage.removeItem(this.getStorageKey())
} catch (error) {
console.error('Failed to clear localStorage:', error)
}
clearStateFromLocalStorage(this.tenantId)
}
// ---------------------------------------------------------------------------
// Sync Methods
// ---------------------------------------------------------------------------
// -- Sync --
queueSync(state: SDKState): void {
this.pendingState = state
@@ -242,7 +153,7 @@ export class StateSyncManager {
this.syncState.localVersion++
this.saveToLocalStorage(state)
this.broadcastState()
broadcastStateUpdate(this.broadcastChannel, state, this.syncState.localVersion)
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
@@ -284,7 +195,7 @@ export class StateSyncManager {
}
this.pendingState = null
this.broadcastSyncComplete(response.version)
broadcastSyncComplete(this.broadcastChannel, response.version)
this.callbacks.onSyncComplete?.(state)
} catch (error) {
if ((error as { status?: number }).status === 409) {
@@ -319,9 +230,7 @@ export class StateSyncManager {
}
}
// ---------------------------------------------------------------------------
// Conflict Resolution
// ---------------------------------------------------------------------------
// -- Conflict Resolution (sync-conflict helpers) --
private async handleConflict(localState: SDKState): Promise<void> {
this.syncState.status = 'conflict'
@@ -338,19 +247,7 @@ export class StateSyncManager {
this.callbacks.onConflict?.(localState, serverState)
const resolution = await this.options.conflictHandler(localState, serverState)
let resolvedState: SDKState
switch (resolution.strategy) {
case 'local':
resolvedState = localState
break
case 'server':
resolvedState = serverState
break
case 'merge':
resolvedState = resolution.mergedState || localState
break
}
const resolvedState = applyConflictResolution(resolution, localState, serverState)
const response = await this.client.saveState(resolvedState)
this.syncState.serverVersion = response.version
@@ -365,38 +262,7 @@ export class StateSyncManager {
}
}
private async defaultConflictHandler(
local: SDKState,
server: SDKState
): Promise<ConflictResolution> {
const localTime = new Date(local.lastModified).getTime()
const serverTime = new Date(server.lastModified).getTime()
if (localTime > serverTime) {
return { strategy: 'local' }
}
const mergedState: SDKState = {
...server,
preferences: local.preferences,
commandBarHistory: [
...local.commandBarHistory,
...server.commandBarHistory.filter(
h => !local.commandBarHistory.some(lh => lh.id === h.id)
),
].slice(0, 50),
recentSearches: [...new Set([...local.recentSearches, ...server.recentSearches])].slice(
0,
20
),
}
return { strategy: 'merge', mergedState }
}
// ---------------------------------------------------------------------------
// Getters & Cleanup
// ---------------------------------------------------------------------------
// -- Getters & Cleanup --
getSyncState(): SyncState {
return { ...this.syncState }
@@ -421,9 +287,7 @@ export class StateSyncManager {
}
}
// =============================================================================
// FACTORY
// =============================================================================
// --- Factory -----------------------------------------------------------------
export function createStateSyncManager(
client: ComplianceClient,

View File

@@ -0,0 +1,164 @@
'use client'
import { useMemo, useCallback } from 'react'
import { useCompliance } from './hooks-core'
import type { Control, Evidence, Risk } from '@breakpilot/compliance-sdk-types'
// =============================================================================
// COMPLIANCE HOOKS
// =============================================================================
export function useComplianceModule() {
const { compliance, state } = useCompliance()
return useMemo(
() => ({
// Controls
controls: state.controls,
getControlById: compliance.getControlById.bind(compliance),
getControlsByDomain: compliance.getControlsByDomain.bind(compliance),
getControlsByStatus: compliance.getControlsByStatus.bind(compliance),
controlComplianceRate: compliance.getControlComplianceRate(),
// Evidence
evidence: state.evidence,
getEvidenceByControlId: compliance.getEvidenceByControlId.bind(compliance),
expiringEvidence: compliance.getExpiringEvidence(),
// Requirements
requirements: state.requirements,
getRequirementsByRegulation: compliance.getRequirementsByRegulation.bind(compliance),
requirementComplianceRate: compliance.getRequirementComplianceRate(),
// Obligations
obligations: state.obligations,
upcomingObligations: compliance.getUpcomingObligations(),
overdueObligations: compliance.getOverdueObligations(),
// AI Act
aiActClassification: state.aiActClassification,
aiActRiskCategory: compliance.getAIActRiskCategory(),
isHighRiskAI: compliance.isHighRiskAI(),
// Score
complianceScore: compliance.calculateComplianceScore(),
// Risks
risks: state.risks,
criticalRisks: compliance.getCriticalRisks(),
averageRiskScore: compliance.getAverageRiskScore(),
}),
[compliance, state]
)
}
export function useControls() {
const { compliance, state, dispatch } = useCompliance()
const addControl = useCallback(
(control: Control) => {
dispatch({ type: 'ADD_CONTROL', payload: control })
},
[dispatch]
)
const updateControl = useCallback(
(id: string, data: Partial<Control>) => {
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
},
[dispatch]
)
return useMemo(
() => ({
controls: state.controls,
addControl,
updateControl,
getById: compliance.getControlById.bind(compliance),
getByDomain: compliance.getControlsByDomain.bind(compliance),
getByStatus: compliance.getControlsByStatus.bind(compliance),
implementedCount: state.controls.filter(c => c.implementationStatus === 'IMPLEMENTED').length,
totalCount: state.controls.length,
complianceRate: compliance.getControlComplianceRate(),
}),
[state.controls, compliance, addControl, updateControl]
)
}
export function useEvidence() {
const { compliance, state, dispatch } = useCompliance()
const addEvidence = useCallback(
(evidence: Evidence) => {
dispatch({ type: 'ADD_EVIDENCE', payload: evidence })
},
[dispatch]
)
const updateEvidence = useCallback(
(id: string, data: Partial<Evidence>) => {
dispatch({ type: 'UPDATE_EVIDENCE', payload: { id, data } })
},
[dispatch]
)
const deleteEvidence = useCallback(
(id: string) => {
dispatch({ type: 'DELETE_EVIDENCE', payload: id })
},
[dispatch]
)
return useMemo(
() => ({
evidence: state.evidence,
addEvidence,
updateEvidence,
deleteEvidence,
getByControlId: compliance.getEvidenceByControlId.bind(compliance),
expiringEvidence: compliance.getExpiringEvidence(),
activeCount: state.evidence.filter(e => e.status === 'ACTIVE').length,
totalCount: state.evidence.length,
}),
[state.evidence, compliance, addEvidence, updateEvidence, deleteEvidence]
)
}
export function useRisks() {
const { compliance, state, dispatch } = useCompliance()
const addRisk = useCallback(
(risk: Risk) => {
dispatch({ type: 'ADD_RISK', payload: risk })
},
[dispatch]
)
const updateRisk = useCallback(
(id: string, data: Partial<Risk>) => {
dispatch({ type: 'UPDATE_RISK', payload: { id, data } })
},
[dispatch]
)
const deleteRisk = useCallback(
(id: string) => {
dispatch({ type: 'DELETE_RISK', payload: id })
},
[dispatch]
)
return useMemo(
() => ({
risks: state.risks,
addRisk,
updateRisk,
deleteRisk,
criticalRisks: compliance.getCriticalRisks(),
getByStatus: compliance.getRisksByStatus.bind(compliance),
getBySeverity: compliance.getRisksBySeverity.bind(compliance),
averageScore: compliance.getAverageRiskScore(),
}),
[state.risks, compliance, addRisk, updateRisk, deleteRisk]
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { useContext } from 'react'
import { ComplianceContext, type ComplianceContextValue } from './provider'
import type { SDKState, SDKAction } from '@breakpilot/compliance-sdk-types'
// =============================================================================
// MAIN HOOK
// =============================================================================
export function useCompliance(): ComplianceContextValue {
const context = useContext(ComplianceContext)
if (!context) {
throw new Error('useCompliance must be used within ComplianceProvider')
}
return context
}
// =============================================================================
// STATE HOOK
// =============================================================================
export function useComplianceState(): SDKState {
const { state } = useCompliance()
return state
}
export function useComplianceDispatch(): React.Dispatch<SDKAction> {
const { dispatch } = useCompliance()
return dispatch
}

View File

@@ -0,0 +1,83 @@
'use client'
import { useMemo } from 'react'
import { useCompliance } from './hooks-core'
import type { ConsentPurpose } from '@breakpilot/compliance-sdk-types'
// =============================================================================
// DSGVO HOOKS
// =============================================================================
export function useDSGVO() {
const { dsgvo, state } = useCompliance()
return useMemo(
() => ({
// DSR
dsrRequests: state.dsrRequests,
dsrConfig: state.dsrConfig,
submitDSR: dsgvo.submitDSR.bind(dsgvo),
// Consent
consents: state.consents,
hasConsent: dsgvo.hasConsent.bind(dsgvo),
getConsentsByUserId: dsgvo.getConsentsByUserId.bind(dsgvo),
// VVT
processingActivities: state.vvt,
getProcessingActivityById: dsgvo.getProcessingActivityById.bind(dsgvo),
// DSFA
dsfa: state.dsfa,
isDSFARequired: dsgvo.isDSFARequired.bind(dsgvo),
// TOMs
toms: state.toms,
getTOMsByCategory: dsgvo.getTOMsByCategory.bind(dsgvo),
getTOMScore: dsgvo.getTOMScore.bind(dsgvo),
// Retention
retentionPolicies: state.retentionPolicies,
getUpcomingDeletions: dsgvo.getUpcomingDeletions.bind(dsgvo),
// Cookie Banner
cookieBanner: state.cookieBanner,
generateCookieBannerCode: dsgvo.generateCookieBannerCode.bind(dsgvo),
}),
[dsgvo, state]
)
}
export function useConsent(userId: string) {
const { dsgvo, state } = useCompliance()
return useMemo(() => {
const userConsents = state.consents.filter(c => c.userId === userId)
return {
consents: userConsents,
hasConsent: (purpose: ConsentPurpose) => dsgvo.hasConsent(userId, purpose),
hasAnalyticsConsent: dsgvo.hasConsent(userId, 'ANALYTICS'),
hasMarketingConsent: dsgvo.hasConsent(userId, 'MARKETING'),
hasFunctionalConsent: dsgvo.hasConsent(userId, 'FUNCTIONAL'),
}
}, [dsgvo, state.consents, userId])
}
export function useDSR() {
const { dsgvo, state } = useCompliance()
return useMemo(
() => ({
requests: state.dsrRequests,
config: state.dsrConfig,
submitRequest: dsgvo.submitDSR.bind(dsgvo),
pendingRequests: state.dsrRequests.filter(r => r.status !== 'COMPLETED' && r.status !== 'REJECTED'),
overdueRequests: state.dsrRequests.filter(r => {
if (r.status === 'COMPLETED' || r.status === 'REJECTED') return false
return new Date(r.dueDate) < new Date()
}),
}),
[dsgvo, state.dsrRequests, state.dsrConfig]
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { useCompliance } from './hooks-core'
// =============================================================================
// RAG HOOKS
// =============================================================================
export function useRAG() {
const { rag } = useCompliance()
return useMemo(
() => ({
search: rag.search.bind(rag),
searchByRegulation: rag.searchByRegulation.bind(rag),
searchByArticle: rag.searchByArticle.bind(rag),
ask: rag.ask.bind(rag),
askAboutRegulation: rag.askAboutRegulation.bind(rag),
explainArticle: rag.explainArticle.bind(rag),
checkCompliance: rag.checkCompliance.bind(rag),
getQuickAnswer: rag.getQuickAnswer.bind(rag),
findRelevantArticles: rag.findRelevantArticles.bind(rag),
availableRegulations: rag.getAvailableRegulations(),
chatHistory: rag.getChatHistory(),
clearChatHistory: rag.clearChatHistory.bind(rag),
startNewSession: rag.startNewSession.bind(rag),
}),
[rag]
)
}
// =============================================================================
// SECURITY HOOKS
// =============================================================================
export function useSecurity() {
const { security, state } = useCompliance()
return useMemo(
() => ({
// SBOM
sbom: state.sbom,
components: security.getComponents(),
vulnerableComponents: security.getVulnerableComponents(),
licenseSummary: security.getLicenseSummary(),
// Issues
issues: state.securityIssues,
openIssues: security.getOpenIssues(),
criticalIssues: security.getCriticalIssues(),
getIssuesBySeverity: security.getIssuesBySeverity.bind(security),
getIssuesByTool: security.getIssuesByTool.bind(security),
// Backlog
backlog: state.securityBacklog,
overdueBacklogItems: security.getOverdueBacklogItems(),
// Scanning
startScan: security.startScan.bind(security),
getScanResult: security.getScanResult.bind(security),
lastScanResult: security.getLastScanResult(),
// Summary
summary: security.getSecuritySummary(),
securityScore: security.getSecurityScore(),
availableTools: security.getAvailableTools(),
}),
[security, state]
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import { useMemo } from 'react'
import { useCompliance } from './hooks-core'
// =============================================================================
// NAVIGATION HOOKS
// =============================================================================
export function useSDKNavigation() {
const {
currentStep,
goToStep,
goToNextStep,
goToPreviousStep,
canGoNext,
canGoPrevious,
completionPercentage,
phase1Completion,
phase2Completion,
state,
} = useCompliance()
return useMemo(
() => ({
currentStep,
currentPhase: state.currentPhase,
completedSteps: state.completedSteps,
goToStep,
goToNextStep,
goToPreviousStep,
canGoNext,
canGoPrevious,
completionPercentage,
phase1Completion,
phase2Completion,
}),
[
currentStep,
state.currentPhase,
state.completedSteps,
goToStep,
goToNextStep,
goToPreviousStep,
canGoNext,
canGoPrevious,
completionPercentage,
phase1Completion,
phase2Completion,
]
)
}
// =============================================================================
// SYNC HOOKS
// =============================================================================
export function useSync() {
const { syncState, forceSyncToServer, isOnline, saveState, loadState } = useCompliance()
return useMemo(
() => ({
status: syncState.status,
lastSyncedAt: syncState.lastSyncedAt,
pendingChanges: syncState.pendingChanges,
error: syncState.error,
isOnline,
isSyncing: syncState.status === 'syncing',
hasConflict: syncState.status === 'conflict',
forceSyncToServer,
saveState,
loadState,
}),
[syncState, isOnline, forceSyncToServer, saveState, loadState]
)
}
// =============================================================================
// CHECKPOINT HOOKS
// =============================================================================
export function useCheckpoints() {
const { validateCheckpoint, overrideCheckpoint, getCheckpointStatus, state } = useCompliance()
return useMemo(
() => ({
checkpoints: state.checkpoints,
validateCheckpoint,
overrideCheckpoint,
getCheckpointStatus,
passedCheckpoints: Object.values(state.checkpoints).filter(c => c.passed).length,
totalCheckpoints: Object.keys(state.checkpoints).length,
}),
[state.checkpoints, validateCheckpoint, overrideCheckpoint, getCheckpointStatus]
)
}
// =============================================================================
// EXPORT HOOKS
// =============================================================================
export function useExport() {
const { exportState } = useCompliance()
return useMemo(
() => ({
exportJSON: () => exportState('json'),
exportPDF: () => exportState('pdf'),
exportZIP: () => exportState('zip'),
exportState,
}),
[exportState]
)
}
// =============================================================================
// COMMAND BAR HOOK
// =============================================================================
export function useCommandBar() {
const { isCommandBarOpen, setCommandBarOpen } = useCompliance()
return useMemo(
() => ({
isOpen: isCommandBarOpen,
open: () => setCommandBarOpen(true),
close: () => setCommandBarOpen(false),
toggle: () => setCommandBarOpen(!isCommandBarOpen),
}),
[isCommandBarOpen, setCommandBarOpen]
)
}

View File

@@ -1,474 +1,43 @@
'use client'
import { useContext, useMemo, useCallback } from 'react'
import { ComplianceContext, type ComplianceContextValue } from './provider'
import type {
SDKState,
SDKAction,
Control,
Evidence,
Risk,
Requirement,
Obligation,
TOM,
ProcessingActivity,
ConsentPurpose,
} from '@breakpilot/compliance-sdk-types'
// =============================================================================
// MAIN HOOK
// =============================================================================
export function useCompliance(): ComplianceContextValue {
const context = useContext(ComplianceContext)
if (!context) {
throw new Error('useCompliance must be used within ComplianceProvider')
}
return context
}
// =============================================================================
// STATE HOOK
// =============================================================================
export function useComplianceState(): SDKState {
const { state } = useCompliance()
return state
}
export function useComplianceDispatch(): React.Dispatch<SDKAction> {
const { dispatch } = useCompliance()
return dispatch
}
// =============================================================================
// DSGVO HOOKS
// =============================================================================
export function useDSGVO() {
const { dsgvo, state } = useCompliance()
return useMemo(
() => ({
// DSR
dsrRequests: state.dsrRequests,
dsrConfig: state.dsrConfig,
submitDSR: dsgvo.submitDSR.bind(dsgvo),
// Consent
consents: state.consents,
hasConsent: dsgvo.hasConsent.bind(dsgvo),
getConsentsByUserId: dsgvo.getConsentsByUserId.bind(dsgvo),
// VVT
processingActivities: state.vvt,
getProcessingActivityById: dsgvo.getProcessingActivityById.bind(dsgvo),
// DSFA
dsfa: state.dsfa,
isDSFARequired: dsgvo.isDSFARequired.bind(dsgvo),
// TOMs
toms: state.toms,
getTOMsByCategory: dsgvo.getTOMsByCategory.bind(dsgvo),
getTOMScore: dsgvo.getTOMScore.bind(dsgvo),
// Retention
retentionPolicies: state.retentionPolicies,
getUpcomingDeletions: dsgvo.getUpcomingDeletions.bind(dsgvo),
// Cookie Banner
cookieBanner: state.cookieBanner,
generateCookieBannerCode: dsgvo.generateCookieBannerCode.bind(dsgvo),
}),
[dsgvo, state]
)
}
export function useConsent(userId: string) {
const { dsgvo, state } = useCompliance()
return useMemo(() => {
const userConsents = state.consents.filter(c => c.userId === userId)
return {
consents: userConsents,
hasConsent: (purpose: ConsentPurpose) => dsgvo.hasConsent(userId, purpose),
hasAnalyticsConsent: dsgvo.hasConsent(userId, 'ANALYTICS'),
hasMarketingConsent: dsgvo.hasConsent(userId, 'MARKETING'),
hasFunctionalConsent: dsgvo.hasConsent(userId, 'FUNCTIONAL'),
}
}, [dsgvo, state.consents, userId])
}
export function useDSR() {
const { dsgvo, state, dispatch } = useCompliance()
return useMemo(
() => ({
requests: state.dsrRequests,
config: state.dsrConfig,
submitRequest: dsgvo.submitDSR.bind(dsgvo),
pendingRequests: state.dsrRequests.filter(r => r.status !== 'COMPLETED' && r.status !== 'REJECTED'),
overdueRequests: state.dsrRequests.filter(r => {
if (r.status === 'COMPLETED' || r.status === 'REJECTED') return false
return new Date(r.dueDate) < new Date()
}),
}),
[dsgvo, state.dsrRequests, state.dsrConfig]
)
}
// =============================================================================
// COMPLIANCE HOOKS
// =============================================================================
export function useComplianceModule() {
const { compliance, state } = useCompliance()
return useMemo(
() => ({
// Controls
controls: state.controls,
getControlById: compliance.getControlById.bind(compliance),
getControlsByDomain: compliance.getControlsByDomain.bind(compliance),
getControlsByStatus: compliance.getControlsByStatus.bind(compliance),
controlComplianceRate: compliance.getControlComplianceRate(),
// Evidence
evidence: state.evidence,
getEvidenceByControlId: compliance.getEvidenceByControlId.bind(compliance),
expiringEvidence: compliance.getExpiringEvidence(),
// Requirements
requirements: state.requirements,
getRequirementsByRegulation: compliance.getRequirementsByRegulation.bind(compliance),
requirementComplianceRate: compliance.getRequirementComplianceRate(),
// Obligations
obligations: state.obligations,
upcomingObligations: compliance.getUpcomingObligations(),
overdueObligations: compliance.getOverdueObligations(),
// AI Act
aiActClassification: state.aiActClassification,
aiActRiskCategory: compliance.getAIActRiskCategory(),
isHighRiskAI: compliance.isHighRiskAI(),
// Score
complianceScore: compliance.calculateComplianceScore(),
// Risks
risks: state.risks,
criticalRisks: compliance.getCriticalRisks(),
averageRiskScore: compliance.getAverageRiskScore(),
}),
[compliance, state]
)
}
export function useControls() {
const { compliance, state, dispatch } = useCompliance()
const addControl = useCallback(
(control: Control) => {
dispatch({ type: 'ADD_CONTROL', payload: control })
},
[dispatch]
)
const updateControl = useCallback(
(id: string, data: Partial<Control>) => {
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
},
[dispatch]
)
return useMemo(
() => ({
controls: state.controls,
addControl,
updateControl,
getById: compliance.getControlById.bind(compliance),
getByDomain: compliance.getControlsByDomain.bind(compliance),
getByStatus: compliance.getControlsByStatus.bind(compliance),
implementedCount: state.controls.filter(c => c.implementationStatus === 'IMPLEMENTED').length,
totalCount: state.controls.length,
complianceRate: compliance.getControlComplianceRate(),
}),
[state.controls, compliance, addControl, updateControl]
)
}
export function useEvidence() {
const { compliance, state, dispatch } = useCompliance()
const addEvidence = useCallback(
(evidence: Evidence) => {
dispatch({ type: 'ADD_EVIDENCE', payload: evidence })
},
[dispatch]
)
const updateEvidence = useCallback(
(id: string, data: Partial<Evidence>) => {
dispatch({ type: 'UPDATE_EVIDENCE', payload: { id, data } })
},
[dispatch]
)
const deleteEvidence = useCallback(
(id: string) => {
dispatch({ type: 'DELETE_EVIDENCE', payload: id })
},
[dispatch]
)
return useMemo(
() => ({
evidence: state.evidence,
addEvidence,
updateEvidence,
deleteEvidence,
getByControlId: compliance.getEvidenceByControlId.bind(compliance),
expiringEvidence: compliance.getExpiringEvidence(),
activeCount: state.evidence.filter(e => e.status === 'ACTIVE').length,
totalCount: state.evidence.length,
}),
[state.evidence, compliance, addEvidence, updateEvidence, deleteEvidence]
)
}
export function useRisks() {
const { compliance, state, dispatch } = useCompliance()
const addRisk = useCallback(
(risk: Risk) => {
dispatch({ type: 'ADD_RISK', payload: risk })
},
[dispatch]
)
const updateRisk = useCallback(
(id: string, data: Partial<Risk>) => {
dispatch({ type: 'UPDATE_RISK', payload: { id, data } })
},
[dispatch]
)
const deleteRisk = useCallback(
(id: string) => {
dispatch({ type: 'DELETE_RISK', payload: id })
},
[dispatch]
)
return useMemo(
() => ({
risks: state.risks,
addRisk,
updateRisk,
deleteRisk,
criticalRisks: compliance.getCriticalRisks(),
getByStatus: compliance.getRisksByStatus.bind(compliance),
getBySeverity: compliance.getRisksBySeverity.bind(compliance),
averageScore: compliance.getAverageRiskScore(),
}),
[state.risks, compliance, addRisk, updateRisk, deleteRisk]
)
}
// =============================================================================
// RAG HOOKS
// =============================================================================
export function useRAG() {
const { rag } = useCompliance()
return useMemo(
() => ({
search: rag.search.bind(rag),
searchByRegulation: rag.searchByRegulation.bind(rag),
searchByArticle: rag.searchByArticle.bind(rag),
ask: rag.ask.bind(rag),
askAboutRegulation: rag.askAboutRegulation.bind(rag),
explainArticle: rag.explainArticle.bind(rag),
checkCompliance: rag.checkCompliance.bind(rag),
getQuickAnswer: rag.getQuickAnswer.bind(rag),
findRelevantArticles: rag.findRelevantArticles.bind(rag),
availableRegulations: rag.getAvailableRegulations(),
chatHistory: rag.getChatHistory(),
clearChatHistory: rag.clearChatHistory.bind(rag),
startNewSession: rag.startNewSession.bind(rag),
}),
[rag]
)
}
// =============================================================================
// SECURITY HOOKS
// =============================================================================
export function useSecurity() {
const { security, state } = useCompliance()
return useMemo(
() => ({
// SBOM
sbom: state.sbom,
components: security.getComponents(),
vulnerableComponents: security.getVulnerableComponents(),
licenseSummary: security.getLicenseSummary(),
// Issues
issues: state.securityIssues,
openIssues: security.getOpenIssues(),
criticalIssues: security.getCriticalIssues(),
getIssuesBySeverity: security.getIssuesBySeverity.bind(security),
getIssuesByTool: security.getIssuesByTool.bind(security),
// Backlog
backlog: state.securityBacklog,
overdueBacklogItems: security.getOverdueBacklogItems(),
// Scanning
startScan: security.startScan.bind(security),
getScanResult: security.getScanResult.bind(security),
lastScanResult: security.getLastScanResult(),
// Summary
summary: security.getSecuritySummary(),
securityScore: security.getSecurityScore(),
availableTools: security.getAvailableTools(),
}),
[security, state]
)
}
// =============================================================================
// NAVIGATION HOOKS
// =============================================================================
export function useSDKNavigation() {
const {
currentStep,
goToStep,
goToNextStep,
goToPreviousStep,
canGoNext,
canGoPrevious,
completionPercentage,
phase1Completion,
phase2Completion,
state,
} = useCompliance()
return useMemo(
() => ({
currentStep,
currentPhase: state.currentPhase,
completedSteps: state.completedSteps,
goToStep,
goToNextStep,
goToPreviousStep,
canGoNext,
canGoPrevious,
completionPercentage,
phase1Completion,
phase2Completion,
}),
[
currentStep,
state.currentPhase,
state.completedSteps,
goToStep,
goToNextStep,
goToPreviousStep,
canGoNext,
canGoPrevious,
completionPercentage,
phase1Completion,
phase2Completion,
]
)
}
// =============================================================================
// SYNC HOOKS
// =============================================================================
export function useSync() {
const { syncState, forceSyncToServer, isOnline, saveState, loadState } = useCompliance()
return useMemo(
() => ({
status: syncState.status,
lastSyncedAt: syncState.lastSyncedAt,
pendingChanges: syncState.pendingChanges,
error: syncState.error,
isOnline,
isSyncing: syncState.status === 'syncing',
hasConflict: syncState.status === 'conflict',
forceSyncToServer,
saveState,
loadState,
}),
[syncState, isOnline, forceSyncToServer, saveState, loadState]
)
}
// =============================================================================
// CHECKPOINT HOOKS
// =============================================================================
export function useCheckpoints() {
const { validateCheckpoint, overrideCheckpoint, getCheckpointStatus, state } = useCompliance()
return useMemo(
() => ({
checkpoints: state.checkpoints,
validateCheckpoint,
overrideCheckpoint,
getCheckpointStatus,
passedCheckpoints: Object.values(state.checkpoints).filter(c => c.passed).length,
totalCheckpoints: Object.keys(state.checkpoints).length,
}),
[state.checkpoints, validateCheckpoint, overrideCheckpoint, getCheckpointStatus]
)
}
// =============================================================================
// EXPORT HOOKS
// =============================================================================
export function useExport() {
const { exportState } = useCompliance()
return useMemo(
() => ({
exportJSON: () => exportState('json'),
exportPDF: () => exportState('pdf'),
exportZIP: () => exportState('zip'),
exportState,
}),
[exportState]
)
}
// =============================================================================
// COMMAND BAR HOOK
// =============================================================================
export function useCommandBar() {
const { isCommandBarOpen, setCommandBarOpen } = useCompliance()
return useMemo(
() => ({
isOpen: isCommandBarOpen,
open: () => setCommandBarOpen(true),
close: () => setCommandBarOpen(false),
toggle: () => setCommandBarOpen(!isCommandBarOpen),
}),
[isCommandBarOpen, setCommandBarOpen]
)
}
/**
* Public re-export barrel for all React hooks.
* All hooks remain importable from this path — split into focused sibling files:
* hooks-core.ts — useCompliance, useComplianceState, useComplianceDispatch
* hooks-dsgvo.ts — useDSGVO, useConsent, useDSR
* hooks-compliance.ts — useComplianceModule, useControls, useEvidence, useRisks
* hooks-rag-security.ts — useRAG, useSecurity
* hooks-ui.ts — useSDKNavigation, useSync, useCheckpoints, useExport, useCommandBar
*/
export {
useCompliance,
useComplianceState,
useComplianceDispatch,
} from './hooks-core'
export {
useDSGVO,
useConsent,
useDSR,
} from './hooks-dsgvo'
export {
useComplianceModule,
useControls,
useEvidence,
useRisks,
} from './hooks-compliance'
export {
useRAG,
useSecurity,
} from './hooks-rag-security'
export {
useSDKNavigation,
useSync,
useCheckpoints,
useExport,
useCommandBar,
} from './hooks-ui'

View File

@@ -0,0 +1,226 @@
'use client'
/**
* useCallback factories extracted from ComplianceProvider.
*
* Each function creates and returns a memoised callback so provider.tsx
* stays under 300 LOC.
*/
import { useCallback, RefObject } from 'react'
import type {
SDKState,
CheckpointStatus,
UseCaseAssessment,
Risk,
Control,
} from '@breakpilot/compliance-sdk-types'
import { ComplianceClient, StateSyncManager } from '@breakpilot/compliance-sdk-core'
import { SDK_STORAGE_KEY } from './provider-context'
// =============================================================================
// CHECKPOINT CALLBACKS
// =============================================================================
export function useValidateCheckpoint(
state: SDKState,
enableBackendSync: boolean,
client: ComplianceClient,
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
) {
return useCallback(
async (checkpointId: string): Promise<CheckpointStatus> => {
if (enableBackendSync) {
try {
const result = await client.validateCheckpoint(checkpointId, state)
const status: CheckpointStatus = {
checkpointId: result.checkpointId,
passed: result.passed,
validatedAt: new Date(result.validatedAt),
validatedBy: result.validatedBy,
errors: result.errors,
warnings: result.warnings,
}
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
return status
} catch {
// Fall through to local validation
}
}
const status: CheckpointStatus = {
checkpointId,
passed: true,
validatedAt: new Date(),
validatedBy: 'SYSTEM',
errors: [],
warnings: [],
}
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
return status
},
[state, enableBackendSync, client, dispatch]
)
}
export function useOverrideCheckpoint(
state: SDKState,
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
) {
return useCallback(
async (checkpointId: string, reason: string): Promise<void> => {
const existingStatus = state.checkpoints[checkpointId]
const overriddenStatus: CheckpointStatus = {
...existingStatus,
checkpointId,
passed: true,
overrideReason: reason,
overriddenBy: state.userId,
overriddenAt: new Date(),
errors: [],
warnings: existingStatus?.warnings || [],
}
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status: overriddenStatus } })
},
[state.checkpoints, state.userId, dispatch]
)
}
export function useGetCheckpointStatus(state: SDKState) {
return useCallback(
(checkpointId: string): CheckpointStatus | undefined => state.checkpoints[checkpointId],
[state.checkpoints]
)
}
// =============================================================================
// STATE UPDATE CALLBACKS
// =============================================================================
export function useUpdateUseCase(dispatch: React.Dispatch<{ type: string; payload?: unknown }>) {
return useCallback(
(id: string, data: Partial<UseCaseAssessment>) => {
dispatch({ type: 'UPDATE_USE_CASE', payload: { id, data } })
},
[dispatch]
)
}
export function useAddRisk(dispatch: React.Dispatch<{ type: string; payload?: unknown }>) {
return useCallback(
(risk: Risk) => {
dispatch({ type: 'ADD_RISK', payload: risk })
},
[dispatch]
)
}
export function useUpdateControl(dispatch: React.Dispatch<{ type: string; payload?: unknown }>) {
return useCallback(
(id: string, data: Partial<Control>) => {
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
},
[dispatch]
)
}
// =============================================================================
// PERSISTENCE CALLBACKS
// =============================================================================
export function useSaveState(
state: SDKState,
tenantId: string,
enableBackendSync: boolean,
syncManagerRef: RefObject<StateSyncManager | null>,
setError: (e: Error) => void
) {
return useCallback(async (): Promise<void> => {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
}
if (enableBackendSync && syncManagerRef.current) {
await syncManagerRef.current.forceSync(state)
}
} catch (err) {
setError(err as Error)
throw err
}
}, [state, tenantId, enableBackendSync, syncManagerRef, setError])
}
export function useLoadState(
tenantId: string,
enableBackendSync: boolean,
syncManagerRef: RefObject<StateSyncManager | null>,
setIsLoading: (v: boolean) => void,
dispatch: React.Dispatch<{ type: string; payload?: unknown }>,
setError: (e: Error) => void
) {
return useCallback(async (): Promise<void> => {
setIsLoading(true)
try {
if (enableBackendSync && syncManagerRef.current) {
const serverState = await syncManagerRef.current.loadFromServer()
if (serverState) {
dispatch({ type: 'SET_STATE', payload: serverState })
return
}
}
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
if (stored) {
dispatch({ type: 'SET_STATE', payload: JSON.parse(stored) })
}
}
} catch (err) {
setError(err as Error)
throw err
} finally {
setIsLoading(false)
}
}, [tenantId, enableBackendSync, syncManagerRef, setIsLoading, dispatch, setError])
}
export function useResetState(
tenantId: string,
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
) {
return useCallback(() => {
dispatch({ type: 'RESET_STATE' })
if (typeof window !== 'undefined') {
localStorage.removeItem(`${SDK_STORAGE_KEY}-${tenantId}`)
}
}, [tenantId, dispatch])
}
// =============================================================================
// SYNC & EXPORT CALLBACKS
// =============================================================================
export function useForceSyncToServer(
state: SDKState,
enableBackendSync: boolean,
syncManagerRef: RefObject<StateSyncManager | null>
) {
return useCallback(async (): Promise<void> => {
if (enableBackendSync && syncManagerRef.current) {
await syncManagerRef.current.forceSync(state)
}
}, [state, enableBackendSync, syncManagerRef])
}
export function useExportState(state: SDKState, client: ComplianceClient) {
return useCallback(
async (format: 'json' | 'pdf' | 'zip'): Promise<Blob> => {
if (format === 'json') {
return new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
}
return client.exportState(format)
},
[state, client]
)
}

View File

@@ -0,0 +1,203 @@
'use client'
/**
* Side-effect hooks extracted from ComplianceProvider.
*
* Each function encapsulates one useEffect concern so provider.tsx
* stays under 300 LOC.
*/
import { useEffect, RefObject } from 'react'
import type { SDKState } from '@breakpilot/compliance-sdk-types'
import {
ComplianceClient,
StateSyncManager,
createStateSyncManager,
} from '@breakpilot/compliance-sdk-core'
import { SDK_STORAGE_KEY } from './provider-context'
// =============================================================================
// TYPES
// =============================================================================
export interface SyncStateSetters {
setSyncState: React.Dispatch<React.SetStateAction<{
status: string
lastSyncedAt: Date | null
localVersion: number
serverVersion: number
pendingChanges: number
error: string | null
}>>
setIsOnline: (v: boolean) => void
setError: (e: Error | null) => void
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
}
// =============================================================================
// SYNC MANAGER EFFECT
// =============================================================================
export function useSyncManagerEffect(
enableBackendSync: boolean,
tenantId: string,
client: ComplianceClient,
state: SDKState,
syncManagerRef: RefObject<StateSyncManager | null>,
{ setSyncState, setIsOnline, setError, dispatch }: SyncStateSetters
): void {
useEffect(() => {
if (enableBackendSync && typeof window !== 'undefined') {
syncManagerRef.current = createStateSyncManager(
client,
tenantId,
{ debounceMs: 2000, maxRetries: 3 },
{
onSyncStart: () => {
setSyncState(prev => ({ ...prev, status: 'syncing' }))
},
onSyncComplete: syncedState => {
setSyncState(prev => ({
...prev,
status: 'idle',
lastSyncedAt: new Date(),
pendingChanges: 0,
}))
if (new Date(syncedState.lastModified) > new Date(state.lastModified)) {
dispatch({ type: 'SET_STATE', payload: syncedState })
}
},
onSyncError: err => {
setSyncState(prev => ({ ...prev, status: 'error', error: err.message }))
setError(err)
},
onConflict: () => {
setSyncState(prev => ({ ...prev, status: 'conflict' }))
},
onOffline: () => {
setIsOnline(false)
setSyncState(prev => ({ ...prev, status: 'offline' }))
},
onOnline: () => {
setIsOnline(true)
setSyncState(prev => ({ ...prev, status: 'idle' }))
},
}
)
}
return () => {
syncManagerRef.current?.destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableBackendSync, tenantId, client])
}
// =============================================================================
// LOAD INITIAL STATE EFFECT
// =============================================================================
export interface LoadStateSetters {
setIsLoading: (v: boolean) => void
setIsInitialized: (v: boolean) => void
setError: (e: Error | null) => void
dispatch: React.Dispatch<{ type: string; payload?: unknown }>
onError?: (e: Error) => void
}
export function useLoadInitialStateEffect(
tenantId: string,
enableBackendSync: boolean,
syncManagerRef: RefObject<StateSyncManager | null>,
{ setIsLoading, setIsInitialized, setError, dispatch, onError }: LoadStateSetters
): void {
useEffect(() => {
const loadInitialState = async () => {
setIsLoading(true)
try {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
if (stored) {
const parsed = JSON.parse(stored)
if (parsed.lastModified) {
parsed.lastModified = new Date(parsed.lastModified)
}
dispatch({ type: 'SET_STATE', payload: parsed })
}
}
if (enableBackendSync && syncManagerRef.current) {
const serverState = await syncManagerRef.current.loadFromServer()
if (serverState) {
dispatch({ type: 'SET_STATE', payload: serverState })
}
}
} catch (err) {
setError(err as Error)
onError?.(err as Error)
} finally {
setIsLoading(false)
setIsInitialized(true)
}
}
loadInitialState()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tenantId, enableBackendSync])
}
// =============================================================================
// AUTO-SAVE EFFECT
// =============================================================================
export function useAutoSaveEffect(
state: SDKState,
tenantId: string,
isInitialized: boolean,
enableBackendSync: boolean,
syncManagerRef: RefObject<StateSyncManager | null>
): void {
useEffect(() => {
if (!isInitialized || !state.preferences.autoSave) return
const saveTimeout = setTimeout(() => {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
}
if (enableBackendSync && syncManagerRef.current) {
syncManagerRef.current.queueSync(state)
}
} catch (err) {
console.error('Failed to save state:', err)
}
}, 1000)
return () => clearTimeout(saveTimeout)
}, [state, tenantId, isInitialized, enableBackendSync])
}
// =============================================================================
// KEYBOARD SHORTCUTS EFFECT
// =============================================================================
export function useKeyboardShortcutsEffect(
isCommandBarOpen: boolean,
setCommandBarOpen: (fn: (prev: boolean) => boolean) => void
): void {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setCommandBarOpen(prev => !prev)
}
if (e.key === 'Escape' && isCommandBarOpen) {
setCommandBarOpen(() => false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isCommandBarOpen, setCommandBarOpen])
}

View File

@@ -2,8 +2,6 @@
import React, {
useReducer,
useEffect,
useCallback,
useMemo,
useRef,
useState,
@@ -13,19 +11,12 @@ import {
sdkReducer,
initialState,
StateSyncManager,
createStateSyncManager,
createDSGVOModule,
createComplianceModule,
createRAGModule,
createSecurityModule,
} from '@breakpilot/compliance-sdk-core'
import type {
CheckpointStatus,
SyncState,
UseCaseAssessment,
Risk,
Control,
} from '@breakpilot/compliance-sdk-types'
import type { SyncState } from '@breakpilot/compliance-sdk-types'
import {
getStepById,
getNextStep,
@@ -35,10 +26,29 @@ import {
} from '@breakpilot/compliance-sdk-types'
import {
ComplianceContext,
SDK_STORAGE_KEY,
type ComplianceContextValue,
type ComplianceProviderProps,
} from './provider-context'
import {
useSyncManagerEffect,
useLoadInitialStateEffect,
useAutoSaveEffect,
useKeyboardShortcutsEffect,
} from './provider-effects'
import {
useValidateCheckpoint,
useOverrideCheckpoint,
useGetCheckpointStatus,
useUpdateUseCase,
useAddRisk,
useUpdateControl,
useSaveState,
useLoadState,
useResetState,
useForceSyncToServer,
useExportState,
} from './provider-callbacks'
import { useCallback, useMemo as useMemoReact } from 'react'
export {
ComplianceContext,
@@ -69,7 +79,7 @@ export function ComplianceProvider({
tenantId,
userId,
})
const [isCommandBarOpen, setCommandBarOpen] = useState(false)
const [isCommandBarOpen, setCommandBarOpenRaw] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
@@ -87,7 +97,7 @@ export function ComplianceProvider({
const clientRef = useRef<ComplianceClient | null>(null)
const syncManagerRef = useRef<StateSyncManager | null>(null)
// Initialize client
// Initialize client (once)
if (!clientRef.current) {
clientRef.current = new ComplianceClient({
apiEndpoint,
@@ -103,144 +113,46 @@ export function ComplianceProvider({
const client = clientRef.current
// Modules
const dsgvo = useMemo(
() => createDSGVOModule(client, () => state),
[client, state]
)
const compliance = useMemo(
() => createComplianceModule(client, () => state),
[client, state]
)
const dsgvo = useMemo(() => createDSGVOModule(client, () => state), [client, state])
const compliance = useMemo(() => createComplianceModule(client, () => state), [client, state])
const rag = useMemo(() => createRAGModule(client), [client])
const security = useMemo(
() => createSecurityModule(client, () => state),
[client, state]
const security = useMemo(() => createSecurityModule(client, () => state), [client, state])
// -------------------------------------------------------------------------
// Effects (extracted to provider-effects.ts)
// -------------------------------------------------------------------------
const syncStateSetters = useMemo(
() => ({ setSyncState, setIsOnline, setError, dispatch }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
// Initialize sync manager
useEffect(() => {
if (enableBackendSync && typeof window !== 'undefined') {
syncManagerRef.current = createStateSyncManager(
client,
tenantId,
{ debounceMs: 2000, maxRetries: 3 },
{
onSyncStart: () => {
setSyncState(prev => ({ ...prev, status: 'syncing' }))
},
onSyncComplete: syncedState => {
setSyncState(prev => ({
...prev,
status: 'idle',
lastSyncedAt: new Date(),
pendingChanges: 0,
}))
if (new Date(syncedState.lastModified) > new Date(state.lastModified)) {
dispatch({ type: 'SET_STATE', payload: syncedState })
}
},
onSyncError: err => {
setSyncState(prev => ({
...prev,
status: 'error',
error: err.message,
}))
setError(err)
},
onConflict: () => {
setSyncState(prev => ({ ...prev, status: 'conflict' }))
},
onOffline: () => {
setIsOnline(false)
setSyncState(prev => ({ ...prev, status: 'offline' }))
},
onOnline: () => {
setIsOnline(true)
setSyncState(prev => ({ ...prev, status: 'idle' }))
},
}
)
}
useSyncManagerEffect(enableBackendSync, tenantId, client, state, syncManagerRef, syncStateSetters)
return () => {
syncManagerRef.current?.destroy()
}
}, [enableBackendSync, tenantId, client])
useLoadInitialStateEffect(tenantId, enableBackendSync, syncManagerRef, {
setIsLoading,
setIsInitialized,
setError,
dispatch,
onError,
})
// Load initial state
useEffect(() => {
const loadInitialState = async () => {
setIsLoading(true)
try {
// Load from localStorage first
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
if (stored) {
const parsed = JSON.parse(stored)
if (parsed.lastModified) {
parsed.lastModified = new Date(parsed.lastModified)
}
dispatch({ type: 'SET_STATE', payload: parsed })
}
}
useAutoSaveEffect(state, tenantId, isInitialized, enableBackendSync, syncManagerRef)
// Then load from server if enabled
if (enableBackendSync && syncManagerRef.current) {
const serverState = await syncManagerRef.current.loadFromServer()
if (serverState) {
dispatch({ type: 'SET_STATE', payload: serverState })
}
}
} catch (err) {
setError(err as Error)
onError?.(err as Error)
} finally {
setIsLoading(false)
setIsInitialized(true)
}
}
const setCommandBarOpen = useCallback(
(fn: boolean | ((prev: boolean) => boolean)) => {
setCommandBarOpenRaw(typeof fn === 'function' ? fn : () => fn)
},
[]
)
loadInitialState()
}, [tenantId, enableBackendSync])
// Auto-save
useEffect(() => {
if (!isInitialized || !state.preferences.autoSave) return
const saveTimeout = setTimeout(() => {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
}
if (enableBackendSync && syncManagerRef.current) {
syncManagerRef.current.queueSync(state)
}
} catch (err) {
console.error('Failed to save state:', err)
}
}, 1000)
return () => clearTimeout(saveTimeout)
}, [state, tenantId, isInitialized, enableBackendSync])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setCommandBarOpen(prev => !prev)
}
if (e.key === 'Escape' && isCommandBarOpen) {
setCommandBarOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isCommandBarOpen])
useKeyboardShortcutsEffect(isCommandBarOpen, setCommandBarOpen as (fn: (prev: boolean) => boolean) => void)
// -------------------------------------------------------------------------
// Navigation
// -------------------------------------------------------------------------
const currentStep = useMemo(() => getStepById(state.currentStep), [state.currentStep])
const goToStep = useCallback(
@@ -256,16 +168,12 @@ export function ComplianceProvider({
const goToNextStep = useCallback(() => {
const nextStep = getNextStep(state.currentStep)
if (nextStep) {
goToStep(nextStep.id)
}
if (nextStep) goToStep(nextStep.id)
}, [state.currentStep, goToStep])
const goToPreviousStep = useCallback(() => {
const prevStep = getPreviousStep(state.currentStep)
if (prevStep) {
goToStep(prevStep.id)
}
if (prevStep) goToStep(prevStep.id)
}, [state.currentStep, goToStep])
const canGoNext = useMemo(() => getNextStep(state.currentStep) !== undefined, [state.currentStep])
@@ -274,152 +182,29 @@ export function ComplianceProvider({
[state.currentStep]
)
// Progress
const completionPercentage = useMemo(() => getCompletionPercentage(state), [state])
const phase1Completion = useMemo(() => getPhaseCompletionPercentage(state, 1), [state])
const phase2Completion = useMemo(() => getPhaseCompletionPercentage(state, 2), [state])
// Checkpoints
const validateCheckpoint = useCallback(
async (checkpointId: string): Promise<CheckpointStatus> => {
if (enableBackendSync) {
try {
const result = await client.validateCheckpoint(checkpointId, state)
const status: CheckpointStatus = {
checkpointId: result.checkpointId,
passed: result.passed,
validatedAt: new Date(result.validatedAt),
validatedBy: result.validatedBy,
errors: result.errors,
warnings: result.warnings,
}
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
return status
} catch {
// Fall through to local validation
}
}
// -------------------------------------------------------------------------
// Callbacks (extracted to provider-callbacks.ts)
// -------------------------------------------------------------------------
// Local validation
const status: CheckpointStatus = {
checkpointId,
passed: true,
validatedAt: new Date(),
validatedBy: 'SYSTEM',
errors: [],
warnings: [],
}
const validateCheckpoint = useValidateCheckpoint(state, enableBackendSync, client, dispatch)
const overrideCheckpoint = useOverrideCheckpoint(state, dispatch)
const getCheckpointStatus = useGetCheckpointStatus(state)
const updateUseCase = useUpdateUseCase(dispatch)
const addRisk = useAddRisk(dispatch)
const updateControl = useUpdateControl(dispatch)
const saveState = useSaveState(state, tenantId, enableBackendSync, syncManagerRef, e => setError(e))
const loadState = useLoadState(tenantId, enableBackendSync, syncManagerRef, setIsLoading, dispatch, e => setError(e))
const resetState = useResetState(tenantId, dispatch)
const forceSyncToServer = useForceSyncToServer(state, enableBackendSync, syncManagerRef)
const exportState = useExportState(state, client)
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
return status
},
[state, enableBackendSync, client]
)
const overrideCheckpoint = useCallback(
async (checkpointId: string, reason: string): Promise<void> => {
const existingStatus = state.checkpoints[checkpointId]
const overriddenStatus: CheckpointStatus = {
...existingStatus,
checkpointId,
passed: true,
overrideReason: reason,
overriddenBy: state.userId,
overriddenAt: new Date(),
errors: [],
warnings: existingStatus?.warnings || [],
}
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status: overriddenStatus } })
},
[state.checkpoints, state.userId]
)
const getCheckpointStatus = useCallback(
(checkpointId: string): CheckpointStatus | undefined => {
return state.checkpoints[checkpointId]
},
[state.checkpoints]
)
// State Updates
const updateUseCase = useCallback((id: string, data: Partial<UseCaseAssessment>) => {
dispatch({ type: 'UPDATE_USE_CASE', payload: { id, data } })
}, [])
const addRisk = useCallback((risk: Risk) => {
dispatch({ type: 'ADD_RISK', payload: risk })
}, [])
const updateControl = useCallback((id: string, data: Partial<Control>) => {
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
}, [])
// Persistence
const saveState = useCallback(async (): Promise<void> => {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
}
if (enableBackendSync && syncManagerRef.current) {
await syncManagerRef.current.forceSync(state)
}
} catch (err) {
setError(err as Error)
throw err
}
}, [state, tenantId, enableBackendSync])
const loadState = useCallback(async (): Promise<void> => {
setIsLoading(true)
try {
if (enableBackendSync && syncManagerRef.current) {
const serverState = await syncManagerRef.current.loadFromServer()
if (serverState) {
dispatch({ type: 'SET_STATE', payload: serverState })
return
}
}
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
if (stored) {
dispatch({ type: 'SET_STATE', payload: JSON.parse(stored) })
}
}
} catch (err) {
setError(err as Error)
throw err
} finally {
setIsLoading(false)
}
}, [tenantId, enableBackendSync])
const resetState = useCallback(() => {
dispatch({ type: 'RESET_STATE' })
if (typeof window !== 'undefined') {
localStorage.removeItem(`${SDK_STORAGE_KEY}-${tenantId}`)
}
}, [tenantId])
// Sync
const forceSyncToServer = useCallback(async (): Promise<void> => {
if (enableBackendSync && syncManagerRef.current) {
await syncManagerRef.current.forceSync(state)
}
}, [state, enableBackendSync])
// Export
const exportState = useCallback(
async (format: 'json' | 'pdf' | 'zip'): Promise<Blob> => {
if (format === 'json') {
return new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
}
return client.exportState(format)
},
[state, client]
)
// -------------------------------------------------------------------------
// Context value
// -------------------------------------------------------------------------
const value: ComplianceContextValue = {
state,
@@ -452,7 +237,7 @@ export function ComplianceProvider({
isOnline,
exportState,
isCommandBarOpen,
setCommandBarOpen,
setCommandBarOpen: setCommandBarOpenRaw,
isInitialized,
isLoading,
error,

View File

@@ -0,0 +1,286 @@
/**
* Render helpers for <breakpilot-dsr-portal>
*
* Provides DSR_PORTAL_STYLES, buildFormHtml, and buildSuccessHtml.
* Kept separate from the element class to stay under the 300-LOC target.
*/
import type { DSRRequestType } from '@breakpilot/compliance-sdk-types'
import { COMMON_STYLES } from './base'
import type { DSRTranslations } from './dsr-portal-translations'
// =============================================================================
// STYLES
// =============================================================================
export const DSR_PORTAL_STYLES = `
${COMMON_STYLES}
:host {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.portal {
background: #fff;
}
.title {
margin: 0 0 10px;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
}
.subtitle {
margin: 0 0 20px;
color: #666;
}
.form-group {
margin-bottom: 20px;
}
.label {
display: block;
font-weight: 500;
margin-bottom: 10px;
}
.type-options {
display: grid;
gap: 10px;
}
.type-option {
display: flex;
padding: 15px;
border: 2px solid #ddd;
border-radius: 8px;
cursor: pointer;
background: #fff;
transition: all 0.2s;
}
.type-option:hover {
border-color: #999;
}
.type-option.selected {
border-color: #1a1a1a;
background: #f5f5f5;
}
.type-option input {
margin-right: 15px;
}
.type-name {
font-weight: 500;
}
.type-description {
font-size: 13px;
color: #666;
}
.input {
width: 100%;
padding: 12px;
font-size: 14px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: #1a1a1a;
}
.textarea {
resize: vertical;
min-height: 100px;
}
.error {
padding: 12px;
background: #fef2f2;
color: #dc2626;
border-radius: 4px;
margin-bottom: 15px;
}
.btn-submit {
width: 100%;
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
color: #fff;
background: #1a1a1a;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.disclaimer {
margin-top: 20px;
font-size: 12px;
color: #666;
}
.success {
text-align: center;
padding: 40px 20px;
background: #f0fdf4;
border-radius: 8px;
}
.success-icon {
font-size: 48px;
margin-bottom: 20px;
}
.success-title {
margin: 0 0 10px;
color: #166534;
}
.success-message {
margin: 0;
color: #166534;
}
`
// =============================================================================
// HTML BUILDERS
// =============================================================================
export interface FormRenderOptions {
t: DSRTranslations
selectedType: DSRRequestType | null
name: string
email: string
additionalInfo: string
isSubmitting: boolean
error: string | null
}
export function buildFormHtml(styles: string, opts: FormRenderOptions): string {
const { t, selectedType, name, email, additionalInfo, isSubmitting, error } = opts
const types: DSRRequestType[] = [
'ACCESS',
'RECTIFICATION',
'ERASURE',
'PORTABILITY',
'RESTRICTION',
'OBJECTION',
]
const typesHtml = types
.map(
type => `
<label class="type-option ${selectedType === type ? 'selected' : ''}">
<input
type="radio"
name="dsrType"
value="${type}"
${selectedType === type ? 'checked' : ''}
/>
<div>
<div class="type-name">${t.types[type].name}</div>
<div class="type-description">${t.types[type].description}</div>
</div>
</label>
`
)
.join('')
const isValid = selectedType && email && name
const isDisabled = !isValid || isSubmitting
return `
<style>${styles}</style>
<div class="portal">
<h2 class="title">${t.title}</h2>
<p class="subtitle">${t.subtitle}</p>
<form id="dsr-form">
<div class="form-group">
<label class="label">${t.requestType} *</label>
<div class="type-options">
${typesHtml}
</div>
</div>
<div class="form-group">
<label class="label">${t.name} *</label>
<input
type="text"
class="input"
id="name-input"
value="${name}"
placeholder="${t.namePlaceholder}"
required
/>
</div>
<div class="form-group">
<label class="label">${t.email} *</label>
<input
type="email"
class="input"
id="email-input"
value="${email}"
placeholder="${t.emailPlaceholder}"
required
/>
</div>
<div class="form-group">
<label class="label">${t.additionalInfo}</label>
<textarea
class="input textarea"
id="info-input"
placeholder="${t.additionalInfoPlaceholder}"
rows="4"
>${additionalInfo}</textarea>
</div>
${error ? `<div class="error">${error}</div>` : ''}
<button
type="submit"
class="btn-submit"
${isDisabled ? 'disabled' : ''}
>
${isSubmitting ? t.submitting : t.submit}
</button>
</form>
<p class="disclaimer">${t.disclaimer}</p>
</div>
`
}
export function buildSuccessHtml(styles: string, t: DSRTranslations, email: string): string {
return `
<style>${styles}</style>
<div class="portal">
<div class="success">
<div class="success-icon">✓</div>
<h2 class="success-title">${t.successTitle}</h2>
<p class="success-message">
${t.successMessage} ${email}.
</p>
</div>
</div>
`
}

View File

@@ -0,0 +1,101 @@
/**
* Translations for <breakpilot-dsr-portal>
*
* Supported languages: 'de' | 'en'
*/
export const DSR_TRANSLATIONS = {
de: {
title: 'Betroffenenrechte-Portal',
subtitle:
'Hier können Sie Ihre Rechte gemäß DSGVO wahrnehmen. Wählen Sie die gewünschte Anfrage und füllen Sie das Formular aus.',
requestType: 'Art der Anfrage',
name: 'Ihr Name',
namePlaceholder: 'Max Mustermann',
email: 'E-Mail-Adresse',
emailPlaceholder: 'max@example.com',
additionalInfo: 'Zusätzliche Informationen (optional)',
additionalInfoPlaceholder: 'Weitere Details zu Ihrer Anfrage...',
submit: 'Anfrage einreichen',
submitting: 'Wird gesendet...',
successTitle: 'Anfrage eingereicht',
successMessage:
'Wir werden Ihre Anfrage innerhalb von 30 Tagen bearbeiten. Sie erhalten eine Bestätigung per E-Mail an',
disclaimer:
'Ihre Anfrage wird gemäß Art. 12 DSGVO innerhalb von einem Monat bearbeitet. In komplexen Fällen kann diese Frist um weitere zwei Monate verlängert werden.',
types: {
ACCESS: {
name: 'Auskunft (Art. 15)',
description: 'Welche Daten haben Sie über mich gespeichert?',
},
RECTIFICATION: {
name: 'Berichtigung (Art. 16)',
description: 'Korrigieren Sie falsche Daten über mich.',
},
ERASURE: {
name: 'Löschung (Art. 17)',
description: 'Löschen Sie alle meine personenbezogenen Daten.',
},
PORTABILITY: {
name: 'Datenübertragbarkeit (Art. 20)',
description: 'Exportieren Sie meine Daten in einem maschinenlesbaren Format.',
},
RESTRICTION: {
name: 'Einschränkung (Art. 18)',
description: 'Schränken Sie die Verarbeitung meiner Daten ein.',
},
OBJECTION: {
name: 'Widerspruch (Art. 21)',
description: 'Ich widerspreche der Verarbeitung meiner Daten.',
},
},
},
en: {
title: 'Data Subject Rights Portal',
subtitle:
'Here you can exercise your rights under GDPR. Select the type of request and fill out the form.',
requestType: 'Request Type',
name: 'Your Name',
namePlaceholder: 'John Doe',
email: 'Email Address',
emailPlaceholder: 'john@example.com',
additionalInfo: 'Additional Information (optional)',
additionalInfoPlaceholder: 'Any additional details about your request...',
submit: 'Submit Request',
submitting: 'Submitting...',
successTitle: 'Request Submitted',
successMessage:
'We will process your request within 30 days. You will receive a confirmation email at',
disclaimer:
'Your request will be processed in accordance with Article 12 GDPR within one month. In complex cases, this period may be extended by up to two additional months.',
types: {
ACCESS: {
name: 'Access (Art. 15)',
description: 'What data do you have about me?',
},
RECTIFICATION: {
name: 'Rectification (Art. 16)',
description: 'Correct inaccurate data about me.',
},
ERASURE: {
name: 'Erasure (Art. 17)',
description: 'Delete all my personal data.',
},
PORTABILITY: {
name: 'Data Portability (Art. 20)',
description: 'Export my data in a machine-readable format.',
},
RESTRICTION: {
name: 'Restriction (Art. 18)',
description: 'Restrict the processing of my data.',
},
OBJECTION: {
name: 'Objection (Art. 21)',
description: 'I object to the processing of my data.',
},
},
},
} as const
export type DSRLanguage = keyof typeof DSR_TRANSLATIONS
export type DSRTranslations = (typeof DSR_TRANSLATIONS)[DSRLanguage]

View File

@@ -8,103 +8,15 @@
* api-key="pk_live_xxx"
* language="de">
* </breakpilot-dsr-portal>
*
* Split: translations → dsr-portal-translations.ts
* styles + HTML builders → dsr-portal-render.ts
*/
import type { DSRRequestType } from '@breakpilot/compliance-sdk-types'
import { BreakPilotElement, COMMON_STYLES } from './base'
const TRANSLATIONS = {
de: {
title: 'Betroffenenrechte-Portal',
subtitle:
'Hier können Sie Ihre Rechte gemäß DSGVO wahrnehmen. Wählen Sie die gewünschte Anfrage und füllen Sie das Formular aus.',
requestType: 'Art der Anfrage',
name: 'Ihr Name',
namePlaceholder: 'Max Mustermann',
email: 'E-Mail-Adresse',
emailPlaceholder: 'max@example.com',
additionalInfo: 'Zusätzliche Informationen (optional)',
additionalInfoPlaceholder: 'Weitere Details zu Ihrer Anfrage...',
submit: 'Anfrage einreichen',
submitting: 'Wird gesendet...',
successTitle: 'Anfrage eingereicht',
successMessage:
'Wir werden Ihre Anfrage innerhalb von 30 Tagen bearbeiten. Sie erhalten eine Bestätigung per E-Mail an',
disclaimer:
'Ihre Anfrage wird gemäß Art. 12 DSGVO innerhalb von einem Monat bearbeitet. In komplexen Fällen kann diese Frist um weitere zwei Monate verlängert werden.',
types: {
ACCESS: {
name: 'Auskunft (Art. 15)',
description: 'Welche Daten haben Sie über mich gespeichert?',
},
RECTIFICATION: {
name: 'Berichtigung (Art. 16)',
description: 'Korrigieren Sie falsche Daten über mich.',
},
ERASURE: {
name: 'Löschung (Art. 17)',
description: 'Löschen Sie alle meine personenbezogenen Daten.',
},
PORTABILITY: {
name: 'Datenübertragbarkeit (Art. 20)',
description: 'Exportieren Sie meine Daten in einem maschinenlesbaren Format.',
},
RESTRICTION: {
name: 'Einschränkung (Art. 18)',
description: 'Schränken Sie die Verarbeitung meiner Daten ein.',
},
OBJECTION: {
name: 'Widerspruch (Art. 21)',
description: 'Ich widerspreche der Verarbeitung meiner Daten.',
},
},
},
en: {
title: 'Data Subject Rights Portal',
subtitle:
'Here you can exercise your rights under GDPR. Select the type of request and fill out the form.',
requestType: 'Request Type',
name: 'Your Name',
namePlaceholder: 'John Doe',
email: 'Email Address',
emailPlaceholder: 'john@example.com',
additionalInfo: 'Additional Information (optional)',
additionalInfoPlaceholder: 'Any additional details about your request...',
submit: 'Submit Request',
submitting: 'Submitting...',
successTitle: 'Request Submitted',
successMessage:
'We will process your request within 30 days. You will receive a confirmation email at',
disclaimer:
'Your request will be processed in accordance with Article 12 GDPR within one month. In complex cases, this period may be extended by up to two additional months.',
types: {
ACCESS: {
name: 'Access (Art. 15)',
description: 'What data do you have about me?',
},
RECTIFICATION: {
name: 'Rectification (Art. 16)',
description: 'Correct inaccurate data about me.',
},
ERASURE: {
name: 'Erasure (Art. 17)',
description: 'Delete all my personal data.',
},
PORTABILITY: {
name: 'Data Portability (Art. 20)',
description: 'Export my data in a machine-readable format.',
},
RESTRICTION: {
name: 'Restriction (Art. 18)',
description: 'Restrict the processing of my data.',
},
OBJECTION: {
name: 'Objection (Art. 21)',
description: 'I object to the processing of my data.',
},
},
},
}
import { BreakPilotElement } from './base'
import { DSR_TRANSLATIONS, type DSRLanguage } from './dsr-portal-translations'
import { DSR_PORTAL_STYLES, buildFormHtml, buildSuccessHtml } from './dsr-portal-render'
export class DSRPortalElement extends BreakPilotElement {
static get observedAttributes(): string[] {
@@ -119,12 +31,12 @@ export class DSRPortalElement extends BreakPilotElement {
private isSubmitted = false
private error: string | null = null
private get language(): 'de' | 'en' {
return (this.getAttribute('language') as 'de' | 'en') || 'de'
private get language(): DSRLanguage {
return (this.getAttribute('language') as DSRLanguage) || 'de'
}
private get t() {
return TRANSLATIONS[this.language]
return DSR_TRANSLATIONS[this.language]
}
private handleTypeSelect = (type: DSRRequestType): void => {
@@ -165,253 +77,23 @@ export class DSRPortalElement extends BreakPilotElement {
}
protected render(): void {
const styles = `
${COMMON_STYLES}
:host {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.portal {
background: #fff;
}
.title {
margin: 0 0 10px;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
}
.subtitle {
margin: 0 0 20px;
color: #666;
}
.form-group {
margin-bottom: 20px;
}
.label {
display: block;
font-weight: 500;
margin-bottom: 10px;
}
.type-options {
display: grid;
gap: 10px;
}
.type-option {
display: flex;
padding: 15px;
border: 2px solid #ddd;
border-radius: 8px;
cursor: pointer;
background: #fff;
transition: all 0.2s;
}
.type-option:hover {
border-color: #999;
}
.type-option.selected {
border-color: #1a1a1a;
background: #f5f5f5;
}
.type-option input {
margin-right: 15px;
}
.type-name {
font-weight: 500;
}
.type-description {
font-size: 13px;
color: #666;
}
.input {
width: 100%;
padding: 12px;
font-size: 14px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.input:focus {
outline: none;
border-color: #1a1a1a;
}
.textarea {
resize: vertical;
min-height: 100px;
}
.error {
padding: 12px;
background: #fef2f2;
color: #dc2626;
border-radius: 4px;
margin-bottom: 15px;
}
.btn-submit {
width: 100%;
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
color: #fff;
background: #1a1a1a;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.disclaimer {
margin-top: 20px;
font-size: 12px;
color: #666;
}
.success {
text-align: center;
padding: 40px 20px;
background: #f0fdf4;
border-radius: 8px;
}
.success-icon {
font-size: 48px;
margin-bottom: 20px;
}
.success-title {
margin: 0 0 10px;
color: #166534;
}
.success-message {
margin: 0;
color: #166534;
}
`
if (this.isSubmitted) {
this.renderSuccess(styles)
this.shadow.innerHTML = buildSuccessHtml(DSR_PORTAL_STYLES, this.t, this.email)
} else {
this.renderForm(styles)
this.renderForm()
}
}
private renderForm(styles: string): void {
const t = this.t
const types: DSRRequestType[] = [
'ACCESS',
'RECTIFICATION',
'ERASURE',
'PORTABILITY',
'RESTRICTION',
'OBJECTION',
]
const typesHtml = types
.map(
type => `
<label class="type-option ${this.selectedType === type ? 'selected' : ''}">
<input
type="radio"
name="dsrType"
value="${type}"
${this.selectedType === type ? 'checked' : ''}
/>
<div>
<div class="type-name">${t.types[type].name}</div>
<div class="type-description">${t.types[type].description}</div>
</div>
</label>
`
)
.join('')
const isValid = this.selectedType && this.email && this.name
const isDisabled = !isValid || this.isSubmitting
this.shadow.innerHTML = `
<style>${styles}</style>
<div class="portal">
<h2 class="title">${t.title}</h2>
<p class="subtitle">${t.subtitle}</p>
<form id="dsr-form">
<div class="form-group">
<label class="label">${t.requestType} *</label>
<div class="type-options">
${typesHtml}
</div>
</div>
<div class="form-group">
<label class="label">${t.name} *</label>
<input
type="text"
class="input"
id="name-input"
value="${this.name}"
placeholder="${t.namePlaceholder}"
required
/>
</div>
<div class="form-group">
<label class="label">${t.email} *</label>
<input
type="email"
class="input"
id="email-input"
value="${this.email}"
placeholder="${t.emailPlaceholder}"
required
/>
</div>
<div class="form-group">
<label class="label">${t.additionalInfo}</label>
<textarea
class="input textarea"
id="info-input"
placeholder="${t.additionalInfoPlaceholder}"
rows="4"
>${this.additionalInfo}</textarea>
</div>
${this.error ? `<div class="error">${this.error}</div>` : ''}
<button
type="submit"
class="btn-submit"
${isDisabled ? 'disabled' : ''}
>
${this.isSubmitting ? t.submitting : t.submit}
</button>
</form>
<p class="disclaimer">${t.disclaimer}</p>
</div>
`
private renderForm(): void {
this.shadow.innerHTML = buildFormHtml(DSR_PORTAL_STYLES, {
t: this.t,
selectedType: this.selectedType,
name: this.name,
email: this.email,
additionalInfo: this.additionalInfo,
isSubmitting: this.isSubmitting,
error: this.error,
})
// Bind events
const form = this.shadow.getElementById('dsr-form') as HTMLFormElement
@@ -439,23 +121,6 @@ export class DSRPortalElement extends BreakPilotElement {
}
})
}
private renderSuccess(styles: string): void {
const t = this.t
this.shadow.innerHTML = `
<style>${styles}</style>
<div class="portal">
<div class="success">
<div class="success-icon">✓</div>
<h2 class="success-title">${t.successTitle}</h2>
<p class="success-message">
${t.successMessage} ${this.email}.
</p>
</div>
</div>
`
}
}
// Register the custom element