From 9ecd3b2d8471f22e44abda8b1f6f1ac7b2c87150 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:40:20 +0200 Subject: [PATCH] refactor(sdk): split hooks, dsr-portal, provider, sync approaching 500 LOC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../packages/core/src/sync-conflict.ts | 67 +++ .../packages/core/src/sync-storage.ts | 154 ++++++ .../packages/core/src/sync.ts | 228 ++------ .../packages/react/src/hooks-compliance.ts | 164 ++++++ .../packages/react/src/hooks-core.ts | 31 ++ .../packages/react/src/hooks-dsgvo.ts | 83 +++ .../packages/react/src/hooks-rag-security.ts | 71 +++ .../packages/react/src/hooks-ui.ts | 132 +++++ .../packages/react/src/hooks.ts | 513 ++---------------- .../packages/react/src/provider-callbacks.ts | 226 ++++++++ .../packages/react/src/provider-effects.ts | 203 +++++++ .../packages/react/src/provider.tsx | 363 +++---------- .../src/web-components/dsr-portal-render.ts | 286 ++++++++++ .../web-components/dsr-portal-translations.ts | 101 ++++ .../vanilla/src/web-components/dsr-portal.ts | 377 +------------ 15 files changed, 1700 insertions(+), 1299 deletions(-) create mode 100644 breakpilot-compliance-sdk/packages/core/src/sync-conflict.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/sync-storage.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/hooks-compliance.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/hooks-core.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/hooks-dsgvo.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/hooks-rag-security.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/hooks-ui.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/provider-callbacks.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/provider-effects.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal-render.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal-translations.ts diff --git a/breakpilot-compliance-sdk/packages/core/src/sync-conflict.ts b/breakpilot-compliance-sdk/packages/core/src/sync-conflict.ts new file mode 100644 index 0000000..e85c41e --- /dev/null +++ b/breakpilot-compliance-sdk/packages/core/src/sync-conflict.ts @@ -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 { + 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 + } +} diff --git a/breakpilot-compliance-sdk/packages/core/src/sync-storage.ts b/breakpilot-compliance-sdk/packages/core/src/sync-storage.ts new file mode 100644 index 0000000..982beeb --- /dev/null +++ b/breakpilot-compliance-sdk/packages/core/src/sync-storage.ts @@ -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 +} diff --git a/breakpilot-compliance-sdk/packages/core/src/sync.ts b/breakpilot-compliance-sdk/packages/core/src/sync.ts index e2dc23e..8634ea3 100644 --- a/breakpilot-compliance-sdk/packages/core/src/sync.ts +++ b/breakpilot-compliance-sdk/packages/core/src/sync.ts @@ -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 } - 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 { 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 { - 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, diff --git a/breakpilot-compliance-sdk/packages/react/src/hooks-compliance.ts b/breakpilot-compliance-sdk/packages/react/src/hooks-compliance.ts new file mode 100644 index 0000000..f4b72fc --- /dev/null +++ b/breakpilot-compliance-sdk/packages/react/src/hooks-compliance.ts @@ -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) => { + 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) => { + 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) => { + 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] + ) +} diff --git a/breakpilot-compliance-sdk/packages/react/src/hooks-core.ts b/breakpilot-compliance-sdk/packages/react/src/hooks-core.ts new file mode 100644 index 0000000..26cba12 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/react/src/hooks-core.ts @@ -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 { + const { dispatch } = useCompliance() + return dispatch +} diff --git a/breakpilot-compliance-sdk/packages/react/src/hooks-dsgvo.ts b/breakpilot-compliance-sdk/packages/react/src/hooks-dsgvo.ts new file mode 100644 index 0000000..5747239 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/react/src/hooks-dsgvo.ts @@ -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] + ) +} diff --git a/breakpilot-compliance-sdk/packages/react/src/hooks-rag-security.ts b/breakpilot-compliance-sdk/packages/react/src/hooks-rag-security.ts new file mode 100644 index 0000000..73d0475 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/react/src/hooks-rag-security.ts @@ -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] + ) +} diff --git a/breakpilot-compliance-sdk/packages/react/src/hooks-ui.ts b/breakpilot-compliance-sdk/packages/react/src/hooks-ui.ts new file mode 100644 index 0000000..8fc4fda --- /dev/null +++ b/breakpilot-compliance-sdk/packages/react/src/hooks-ui.ts @@ -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] + ) +} diff --git a/breakpilot-compliance-sdk/packages/react/src/hooks.ts b/breakpilot-compliance-sdk/packages/react/src/hooks.ts index c5e433c..71b7875 100644 --- a/breakpilot-compliance-sdk/packages/react/src/hooks.ts +++ b/breakpilot-compliance-sdk/packages/react/src/hooks.ts @@ -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 { - 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) => { - 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) => { - 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) => { - 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' diff --git a/breakpilot-compliance-sdk/packages/react/src/provider-callbacks.ts b/breakpilot-compliance-sdk/packages/react/src/provider-callbacks.ts new file mode 100644 index 0000000..8a74e30 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/react/src/provider-callbacks.ts @@ -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 => { + 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 => { + 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) => { + 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) => { + dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } }) + }, + [dispatch] + ) +} + +// ============================================================================= +// PERSISTENCE CALLBACKS +// ============================================================================= + +export function useSaveState( + state: SDKState, + tenantId: string, + enableBackendSync: boolean, + syncManagerRef: RefObject, + setError: (e: Error) => void +) { + return useCallback(async (): Promise => { + 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, + setIsLoading: (v: boolean) => void, + dispatch: React.Dispatch<{ type: string; payload?: unknown }>, + setError: (e: Error) => void +) { + return useCallback(async (): Promise => { + 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 +) { + return useCallback(async (): Promise => { + 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 => { + if (format === 'json') { + return new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }) + } + return client.exportState(format) + }, + [state, client] + ) +} diff --git a/breakpilot-compliance-sdk/packages/react/src/provider-effects.ts b/breakpilot-compliance-sdk/packages/react/src/provider-effects.ts new file mode 100644 index 0000000..2c99111 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/react/src/provider-effects.ts @@ -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> + 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, + { 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, + { 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 +): 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]) +} diff --git a/breakpilot-compliance-sdk/packages/react/src/provider.tsx b/breakpilot-compliance-sdk/packages/react/src/provider.tsx index 9ff3d12..e6fa344 100644 --- a/breakpilot-compliance-sdk/packages/react/src/provider.tsx +++ b/breakpilot-compliance-sdk/packages/react/src/provider.tsx @@ -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(null) @@ -87,7 +97,7 @@ export function ComplianceProvider({ const clientRef = useRef(null) const syncManagerRef = useRef(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 => { - 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 => { - 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) => { - 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) => { - dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } }) - }, []) - - // Persistence - const saveState = useCallback(async (): Promise => { - 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 => { - 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 => { - if (enableBackendSync && syncManagerRef.current) { - await syncManagerRef.current.forceSync(state) - } - }, [state, enableBackendSync]) - - // Export - const exportState = useCallback( - async (format: 'json' | 'pdf' | 'zip'): Promise => { - 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, diff --git a/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal-render.ts b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal-render.ts new file mode 100644 index 0000000..7c51d45 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal-render.ts @@ -0,0 +1,286 @@ +/** + * Render helpers for + * + * 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 => ` + + ` + ) + .join('') + + const isValid = selectedType && email && name + const isDisabled = !isValid || isSubmitting + + return ` + +
+

${t.title}

+

${t.subtitle}

+ +
+
+ +
+ ${typesHtml} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + ${error ? `
${error}
` : ''} + + +
+ +

${t.disclaimer}

+
+ ` +} + +export function buildSuccessHtml(styles: string, t: DSRTranslations, email: string): string { + return ` + +
+
+
+

${t.successTitle}

+

+ ${t.successMessage} ${email}. +

+
+
+ ` +} diff --git a/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal-translations.ts b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal-translations.ts new file mode 100644 index 0000000..bee3c61 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal-translations.ts @@ -0,0 +1,101 @@ +/** + * Translations for + * + * 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] diff --git a/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal.ts b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal.ts index bf30bfa..57f113a 100644 --- a/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal.ts +++ b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal.ts @@ -8,103 +8,15 @@ * api-key="pk_live_xxx" * language="de"> * + * + * 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 => ` - - ` - ) - .join('') - - const isValid = this.selectedType && this.email && this.name - const isDisabled = !isValid || this.isSubmitting - - this.shadow.innerHTML = ` - -
-

${t.title}

-

${t.subtitle}

- -
-
- -
- ${typesHtml} -
-
- -
- - -
- -
- - -
- -
- - -
- - ${this.error ? `
${this.error}
` : ''} - - -
- -

${t.disclaimer}

-
- ` + 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 = ` - -
-
-
-

${t.successTitle}

-

- ${t.successMessage} ${this.email}. -

-
-
- ` - } } // Register the custom element