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:
parent
f27345e0ba
commit
13898d1012
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
86
userwallet/src/hooks/useRelayNotifications.ts
Normal file
86
userwallet/src/hooks/useRelayNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
163
userwallet/src/utils/cnilValidation.ts
Normal file
163
userwallet/src/utils/cnilValidation.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user