From 13898d10128b8a3aa4e6afd04bdef523c8b72a3f Mon Sep 17 00:00:00 2001 From: ncantu Date: Wed, 28 Jan 2026 01:42:26 +0100 Subject: [PATCH] UserWallet: useRelayNotifications, CNIL validation, ServiceSync polling, SyncScreen link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Motivations:** - Intégrer les notifications relais (polling, auto-process hashes) dans ServiceSyncScreen - Valider conformité CNIL des contrats/champs à l’ajout au graphe - Accéder à la sync par service depuis SyncScreen **Root causes:** - N/A (évolutions + petits correctifs) **Correctifs:** - relayNotificationService: suppression import inutilisé RelayConfig **Evolutions:** - useRelayNotifications: hook (RelayNotificationService, start/stop polling, auto-process hash) - cnilValidation: validateContractCNIL, validateChampCNIL (valid, errors, warnings) - graphResolver: addContrat/addChamp appellent validation CNIL (async, logs warnings/errors, non bloquant) - ServiceSyncScreen: useRelayNotifications, GraphResolver ref, polling selon configs (fréquence min), sync result (hasMessages, « Aucun nouveau message »), useBloom mention - SyncScreen: bouton « Sync par service » vers /service-sync **Pages affectées:** - userwallet: useRelayNotifications, cnilValidation, ServiceSyncScreen, SyncScreen, graphResolver, relayNotificationService --- .../src/components/ServiceSyncScreen.tsx | 104 ++++++++--- userwallet/src/components/SyncScreen.tsx | 5 +- userwallet/src/hooks/useRelayNotifications.ts | 86 +++++++++ userwallet/src/services/graphResolver.ts | 34 ++++ .../src/services/relayNotificationService.ts | 1 - userwallet/src/utils/cnilValidation.ts | 163 ++++++++++++++++++ 6 files changed, 371 insertions(+), 22 deletions(-) create mode 100644 userwallet/src/hooks/useRelayNotifications.ts create mode 100644 userwallet/src/utils/cnilValidation.ts diff --git a/userwallet/src/components/ServiceSyncScreen.tsx b/userwallet/src/components/ServiceSyncScreen.tsx index d391435..e837f12 100644 --- a/userwallet/src/components/ServiceSyncScreen.tsx +++ b/userwallet/src/components/ServiceSyncScreen.tsx @@ -1,10 +1,12 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useIdentity } from '../hooks/useIdentity'; import { useErrorHandler } from '../hooks/useErrorHandler'; import { ErrorDisplay } from './ErrorDisplay'; import { getStoredRelays } from '../utils/relay'; import { GraphResolver } from '../services/graphResolver'; import { SyncService } from '../services/syncService'; +import { useRelayNotifications } from '../hooks/useRelayNotifications'; import type { ServiceStatus } from '../types/identity'; interface ServiceSyncConfig { @@ -18,6 +20,7 @@ interface ServiceSyncConfig { } export function ServiceSyncScreen(): JSX.Element { + const navigate = useNavigate(); const { identity } = useIdentity(); const { error, handleError, clearError } = useErrorHandler(); const [services, setServices] = useState([]); @@ -29,12 +32,22 @@ export function ServiceSyncScreen(): JSX.Element { Map >(new Map()); + const graphResolverRef = useRef(null); + if (graphResolverRef.current === null) { + graphResolverRef.current = new GraphResolver(); + } + const { startPolling, stopPolling } = useRelayNotifications( + graphResolverRef.current, + true, + ); + + // Load services and configs on mount useEffect(() => { - if (identity === null) { + if (identity === null || graphResolverRef.current === null) { return; } - const graphResolver = new GraphResolver(); + const graphResolver = graphResolverRef.current; const syncService = new SyncService( getStoredRelays().filter((r) => r.enabled), graphResolver, @@ -44,7 +57,13 @@ export function ServiceSyncScreen(): JSX.Element { void (async (): Promise => { await syncService.init(); const svcs = graphResolver.getServices(); - setServices(svcs.map((s) => ({ service_uuid: s.uuid }))); + setServices( + svcs.map((s) => ({ + service_uuid: s.uuid, + contrat_complet: true, + contrat_valide: true, + })), + ); // Load configs from localStorage const stored = localStorage.getItem('userwallet_service_sync_configs'); @@ -62,6 +81,34 @@ export function ServiceSyncScreen(): JSX.Element { })(); }, [identity]); + // Start/stop polling based on configs + useEffect(() => { + const enabledConfigs = Array.from(configs.values()).filter( + (c) => c.enabled, + ); + if (enabledConfigs.length > 0) { + const minInterval = Math.min( + ...enabledConfigs.map((c) => { + switch (c.frequency) { + case 'min': + return 60000; + case 'hour': + return 3600000; + case 'day': + return 86400000; + default: + return 3600000; + } + }), + ); + startPolling(minInterval); + return () => { + stopPolling(); + }; + } + return undefined; + }, [configs, startPolling, stopPolling]); + const saveConfigs = useCallback((newConfigs: Map): void => { const obj = Object.fromEntries(newConfigs); localStorage.setItem('userwallet_service_sync_configs', JSON.stringify(obj)); @@ -112,7 +159,10 @@ export function ServiceSyncScreen(): JSX.Element { const now = Date.now(); const start = now - config.windowHours * 3600000; - const graphResolver = new GraphResolver(); + if (graphResolverRef.current === null) { + graphResolverRef.current = new GraphResolver(); + } + const graphResolver = graphResolverRef.current; const syncService = new SyncService( getStoredRelays().filter((r) => r.enabled), graphResolver, @@ -120,14 +170,21 @@ export function ServiceSyncScreen(): JSX.Element { ); await syncService.init(); + // Use Bloom filter if enabled + if (config.useBloom) { + // Bloom filter is already used in syncService via getKeysInWindow + // which skips hashes already seen + } + const result = await syncService.sync(start, now, serviceUuid); const newResults = new Map(syncResults); + const hasMessages = result.messages > 0 || result.newMessages > 0; newResults.set(serviceUuid, { - ok: result.ok, - message: result.ok + ok: hasMessages, + message: hasMessages ? `Sync OK: ${result.newMessages} nouveaux messages` - : 'Erreur lors de la synchronisation', + : 'Aucun nouveau message', }); setSyncResults(newResults); @@ -148,18 +205,6 @@ export function ServiceSyncScreen(): JSX.Element { [identity, getConfig, syncResults, updateConfig, handleError, clearError], ); - const getFrequencyMs = (frequency: 'min' | 'hour' | 'day'): number => { - switch (frequency) { - case 'min': - return 60000; - case 'hour': - return 3600000; - case 'day': - return 86400000; - default: - return 3600000; - } - }; return (
@@ -311,6 +356,25 @@ export function ServiceSyncScreen(): JSX.Element { + +
+ + +
); } diff --git a/userwallet/src/components/SyncScreen.tsx b/userwallet/src/components/SyncScreen.tsx index 291395b..5b0e62d 100644 --- a/userwallet/src/components/SyncScreen.tsx +++ b/userwallet/src/components/SyncScreen.tsx @@ -125,7 +125,10 @@ export function SyncScreen(): JSX.Element { )} )} -
+
+
diff --git a/userwallet/src/hooks/useRelayNotifications.ts b/userwallet/src/hooks/useRelayNotifications.ts new file mode 100644 index 0000000..9c7df60 --- /dev/null +++ b/userwallet/src/hooks/useRelayNotifications.ts @@ -0,0 +1,86 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { RelayNotificationService } from '../services/relayNotificationService'; +import { GraphResolver } from '../services/graphResolver'; +import { useIdentity } from './useIdentity'; +import type { RelayHashEvent } from '../services/relayNotificationService'; + +/** + * Hook for relay notifications. + * Automatically processes new hashes when detected. + */ +export function useRelayNotifications( + graphResolver: GraphResolver, + autoProcess: boolean = true, +): { + notificationService: RelayNotificationService | null; + startPolling: (intervalMs?: number) => void; + stopPolling: () => void; +} { + const { identity } = useIdentity(); + const serviceRef = useRef(null); + + useEffect(() => { + if (graphResolver === null) { + return; + } + + serviceRef.current = new RelayNotificationService(graphResolver, identity ?? null); + + // Auto-process hashes when detected + if (autoProcess) { + const listener = async (event: RelayHashEvent): Promise => { + if (serviceRef.current === null) { + return; + } + console.info( + `[RelayNotifications] New hash detected: ${event.hash.slice(0, 16)}... from ${event.relay}`, + ); + // Process hash: fetch message, signatures, keys, decrypt and update graph + const result = await serviceRef.current.processHash(event.hash, { + fetchMessage: true, + fetchSignatures: true, + fetchKeys: true, + decryptAndUpdateGraph: true, + }); + if (result.graphUpdated) { + console.info( + `[RelayNotifications] Graph updated from hash ${event.hash.slice(0, 16)}...`, + ); + } else if (result.error !== undefined) { + console.warn( + `[RelayNotifications] Error processing hash ${event.hash.slice(0, 16)}...: ${result.error}`, + ); + } + }; + + serviceRef.current.addHashListener(listener); + + return () => { + if (serviceRef.current !== null) { + serviceRef.current.removeHashListener(listener); + } + }; + } + }, [graphResolver, identity, autoProcess]); + + const startPolling = useCallback( + (intervalMs: number = 60000): void => { + if (serviceRef.current !== null) { + serviceRef.current.startPolling(intervalMs); + } + }, + [], + ); + + const stopPolling = useCallback((): void => { + if (serviceRef.current !== null) { + serviceRef.current.stopPolling(); + } + }, []); + + return { + notificationService: serviceRef.current, + startPolling, + stopPolling, + }; +} diff --git a/userwallet/src/services/graphResolver.ts b/userwallet/src/services/graphResolver.ts index e5f5f35..71556c2 100644 --- a/userwallet/src/services/graphResolver.ts +++ b/userwallet/src/services/graphResolver.ts @@ -50,6 +50,23 @@ export class GraphResolver { */ addContrat(contrat: Contrat): void { this.cache.contrats.set(contrat.uuid, contrat); + // Validate CNIL compliance (warnings only, not blocking) + void (async (): Promise => { + const { validateContractCNIL } = await import('../utils/cnilValidation'); + const validation = validateContractCNIL(contrat); + if (validation.warnings.length > 0) { + console.warn( + `[GraphResolver] CNIL warnings for contrat ${contrat.uuid}:`, + validation.warnings, + ); + } + if (validation.errors.length > 0) { + console.error( + `[GraphResolver] CNIL errors for contrat ${contrat.uuid}:`, + validation.errors, + ); + } + })(); } /** @@ -57,6 +74,23 @@ export class GraphResolver { */ addChamp(champ: Champ): void { this.cache.champs.set(champ.uuid, champ); + // Validate CNIL compliance (warnings only, not blocking) + void (async (): Promise => { + const { validateChampCNIL } = await import('../utils/cnilValidation'); + const validation = validateChampCNIL(champ); + if (validation.warnings.length > 0) { + console.warn( + `[GraphResolver] CNIL warnings for champ ${champ.uuid}:`, + validation.warnings, + ); + } + if (validation.errors.length > 0) { + console.error( + `[GraphResolver] CNIL errors for champ ${champ.uuid}:`, + validation.errors, + ); + } + })(); } /** diff --git a/userwallet/src/services/relayNotificationService.ts b/userwallet/src/services/relayNotificationService.ts index 515892c..b813354 100644 --- a/userwallet/src/services/relayNotificationService.ts +++ b/userwallet/src/services/relayNotificationService.ts @@ -8,7 +8,6 @@ import { tryDecryptWithKeys } from './syncDecrypt'; import { updateGraphFromMessage } from './syncUpdateGraph'; import { validateDecryptedMessage } from './syncValidate'; import type { LocalIdentity } from '../types/identity'; -import type { RelayConfig } from '../types/identity'; import type { GraphResolver } from './graphResolver'; import type { MsgChiffre, MsgSignature, MsgCle } from '../types/message'; diff --git a/userwallet/src/utils/cnilValidation.ts b/userwallet/src/utils/cnilValidation.ts new file mode 100644 index 0000000..6316d13 --- /dev/null +++ b/userwallet/src/utils/cnilValidation.ts @@ -0,0 +1,163 @@ +import type { Contrat, Champ } from '../types/contract'; +import type { DataJson } from '../types/message'; + +/** + * CNIL validation result. + */ +export interface CNILValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Check if a contract requires CNIL fields based on its type. + */ +function contractRequiresCNIL(_contrat: Contrat): boolean { + // Tous les contrats nécessitent les champs CNIL selon la politique métier + // Peut être ajusté selon les besoins spécifiques + return true; +} + +/** + * Validate CNIL fields in datajson. + */ +function validateCNILFields(datajson?: DataJson): { + valid: boolean; + errors: string[]; + warnings: string[]; +} { + const errors: string[] = []; + const warnings: string[] = []; + + if (datajson === undefined || datajson === null) { + errors.push('datajson manquant'); + return { valid: false, errors, warnings }; + } + + // Vérifier raisons_usage_tiers + if (datajson.raisons_usage_tiers !== undefined) { + if (!Array.isArray(datajson.raisons_usage_tiers)) { + errors.push('raisons_usage_tiers doit être un tableau'); + } else { + for (const item of datajson.raisons_usage_tiers) { + if ( + typeof item !== 'object' || + item === null || + !Array.isArray(item.raisons) || + typeof item.tiers !== 'string' + ) { + errors.push( + 'raisons_usage_tiers: chaque élément doit avoir raisons (string[]) et tiers (string)', + ); + } + } + } + } + + // Vérifier raisons_partage_tiers + if (datajson.raisons_partage_tiers !== undefined) { + if (!Array.isArray(datajson.raisons_partage_tiers)) { + errors.push('raisons_partage_tiers doit être un tableau'); + } else { + for (const item of datajson.raisons_partage_tiers) { + if ( + typeof item !== 'object' || + item === null || + !Array.isArray(item.raisons) || + typeof item.tiers !== 'string' + ) { + errors.push( + 'raisons_partage_tiers: chaque élément doit avoir raisons (string[]) et tiers (string)', + ); + } + } + } + } + + // Vérifier conditions_conservation + if (datajson.conditions_conservation !== undefined) { + if (typeof datajson.conditions_conservation !== 'object' || datajson.conditions_conservation === null) { + errors.push('conditions_conservation doit être un objet'); + } else { + const cc = datajson.conditions_conservation as Record; + if (typeof cc.delai_expiration !== 'string' && typeof cc.delai_expiration !== 'number') { + errors.push('conditions_conservation.delai_expiration requis (string ou number)'); + } + } + } else { + warnings.push('conditions_conservation non défini (recommandé pour conformité CNIL)'); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate CNIL compliance for a contract. + * Returns validation result with errors and warnings. + */ +export function validateContractCNIL(contrat: Contrat): CNILValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!contractRequiresCNIL(contrat)) { + return { valid: true, errors: [], warnings: [] }; + } + + const datajson = contrat.datajson; + const cnilValidation = validateCNILFields(datajson); + + errors.push(...cnilValidation.errors); + warnings.push(...cnilValidation.warnings); + + // Vérifier que les champs obligatoires sont présents si requis par la politique + // Pour l'instant, on génère seulement des warnings, pas d'erreurs strictes + // La politique métier peut être ajustée ici + if (datajson?.raisons_usage_tiers === undefined) { + warnings.push('raisons_usage_tiers non défini (recommandé pour conformité CNIL)'); + } + + if (datajson?.raisons_partage_tiers === undefined) { + warnings.push('raisons_partage_tiers non défini (recommandé pour conformité CNIL)'); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate CNIL compliance for a field (Champ). + */ +export function validateChampCNIL(champ: Champ): CNILValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const datajson = champ.datajson; + const cnilValidation = validateCNILFields(datajson); + + errors.push(...cnilValidation.errors); + warnings.push(...cnilValidation.warnings); + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Check if CNIL fields are required for a contract type. + * Can be customized based on business policy. + */ +export function isCNILRequiredForContract(_contrat: Contrat): boolean { + // Politique métier : tous les contrats nécessitent les champs CNIL + // Peut être ajusté selon le type de contrat + return true; +}