UserWallet: useRelayNotifications, CNIL validation, ServiceSync polling, SyncScreen link

**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
This commit is contained in:
ncantu 2026-01-28 01:42:26 +01:00
parent f27345e0ba
commit 13898d1012
6 changed files with 371 additions and 22 deletions

View File

@ -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<ServiceStatus[]>([]);
@ -29,12 +32,22 @@ export function ServiceSyncScreen(): JSX.Element {
Map<string, { ok: boolean; message?: string }>
>(new Map());
const graphResolverRef = useRef<GraphResolver | null>(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<void> => {
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<string, ServiceSyncConfig>): 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 (
<main>
@ -311,6 +356,25 @@ export function ServiceSyncScreen(): JSX.Element {
</li>
</ul>
</section>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
onClick={(): void => {
navigate('/sync');
}}
>
Sync globale
</button>
<button
type="button"
onClick={(): void => {
navigate('/');
}}
>
Retour
</button>
</div>
</main>
);
}

View File

@ -125,7 +125,10 @@ export function SyncScreen(): JSX.Element {
)}
</section>
)}
<div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button onClick={() => navigate('/service-sync')}>
Sync par service
</button>
<button onClick={() => navigate('/')}>Retour</button>
</div>
</main>

View File

@ -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<RelayNotificationService | null>(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<void> => {
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,
};
}

View File

@ -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<void> => {
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<void> => {
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,
);
}
})();
}
/**

View File

@ -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';

View File

@ -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<string, unknown>;
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;
}