ncantu f9fe0e3419 Website-skeleton partie connectée, contrat en dur, navigate-login; UserWallet pairing-relay-status, redirect; website-data, proxy data, cryptographie, fixKnowledge
**Motivations:**
- Partie connectée du skeleton accessible seulement si pairing satisfait + relais OK, avec page type skeleton (avatar, notifications).
- Éviter « Aucun service disponible » : contrat présent en dur dans la page, transmis à l’iframe ; navigation évidente ou automatique vers login.
- Sécuriser postMessage (origine UserWallet uniquement) ; déployer data sur le proxy et certificat data.certificator.4nkweb.com.
- Vulgariser cryptographie (ECDH, AES-GCM, Schnorr, workflow, collecte signatures) ; documenter correctifs et architecture.

**Root causes:**
- Section connectée affichée sans vérifier pairing/relay ; possibilité de forger pairing-relay-status depuis la console.
- Iframe masquée ou /login chargé avant réception du contrat → graphe vide, redirection vers /services.
- Pas de contrôle d’origine sur les messages reçus ; pas de projet website-data ni config Nginx/certificat pour data.

**Correctifs:**
- Vérification msg.origin === USERWALLET_ORIGIN dans handleMessage (skeleton).
- Si session mais pas pairingRelayStatus : afficher iframe pour réception du statut, message « Vérification du statut… ».
- Contrat envoyé dès load iframe (init iframe.src = USERWALLET_ORIGIN) ; au clic « Se connecter », envoi contract + navigate-login (service, membre).
- UserWallet : écoute navigate-login → navigation /login?service=&membre= ; LoginScreen avec service+membre en URL ne redirige plus vers /services, dispatch E_SELECT_SERVICE / E_SELECT_MEMBER.

**Evolutions:**
- Message pairing-relay-status (iframe → parent) ; canShowConnectedSection exige login + pairing OK + relay OK ; page connectée avec header avatar + icône notifications.
- Skeleton : getLoginContext, sendNavigateLoginToIframe, onIframeLoad, loginRequested/iframeLoaded ; contrat envoyé avec serviceUuid, membreUuid.
- UserWallet : PairingRelayStatusMessage, envoi depuis HomeScreen/LoginScreen ; type navigate-login, handleNavigateLogin dans useChannel.
- Page cryptographie.html (workflow, algorithmes, collecte signatures) ; liens nav, build.
- website-data (Vite, channel, config), start/service/install ; configure-nginx-proxy + Certbot pour data.certificator.4nkweb.com.
- fixKnowledge (postmessage-origin, section-connectee-non-affichee) ; features (partie-connectee-pairing-relay, userwallet-iframe-key-isolation).

**Pages affectées:**
- website-skeleton (index, main, config, serviceContract, cryptographie, technique, membre, contrat, vite.config, README).
- userwallet (HomeScreen, LoginScreen, useChannel, iframeChannel, relay, crypto, iframe, Pairing*, RelaySettings, WordInputGrid, syncUpdateGraph, specs/synthese).
- website-data (nouveau), configure-nginx-proxy, docs DOMAINS_AND_PORTS README, features, fixKnowledge, userwallet features/docs.
2026-01-29 00:55:58 +01:00

1079 lines
37 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useIdentity } from '../hooks/useIdentity';
import { useErrorHandler } from '../hooks/useErrorHandler';
import { useLoginStateMachine } from '../hooks/useLoginStateMachine';
import { ErrorDisplay } from './ErrorDisplay';
import { LoginCollectShare } from './LoginCollectShare';
import { getStoredRelays } from '../utils/relay';
import { GraphResolver } from '../services/graphResolver';
import { LoginBuilder } from '../services/loginBuilder';
import { useChannel } from '../hooks/useChannel';
import { isInIframe, sendToChannel } from '../utils/iframeChannel';
import { isPairingSatisfied, getPairsForMember } from '../utils/pairing';
import {
buildAllowedPubkeys,
checkDependenciesSatisfied,
collectProgress,
hasRemoteSignatures,
} from '../utils/loginValidation';
import { publishMessageAndSigs } from '../utils/loginPublish';
import {
buildPairToMembers,
buildPubkeyToPair,
runCollectLoop,
COLLECT_POLL_MS,
COLLECT_TIMEOUT_MS,
type ProofSignature,
} from '../utils/collectSignatures';
import {
verifyTimestamp,
verifyMessageSignaturesStrict,
} from '../utils/verification';
import * as nonceStore from '../utils/nonceStore';
import type { LoginPath, LoginProof } from '../types/identity';
import type { MessageBase } from '../types/message';
export function LoginScreen(): JSX.Element {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { identity } = useIdentity();
const { error, handleError, clearError } = useErrorHandler();
const { sendLoginProof } = useChannel();
const { state: loginState, dispatch } = useLoginStateMachine();
const serviceUuid = searchParams.get('service') ?? '';
const membreUuid = searchParams.get('membre') ?? '';
const [loginPath, setLoginPath] = useState<LoginPath | null>(null);
const [proof, setProof] = useState<LoginProof | null>(null);
const [isBuilding, setIsBuilding] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [isCollecting, setIsCollecting] = useState(false);
const [awaitingRemoteAccept, setAwaitingRemoteAccept] = useState(false);
const [collectedMerged, setCollectedMerged] = useState<ProofSignature[] | null>(
null,
);
const [collectedPublishStats, setCollectedPublishStats] = useState<{
successCount: number;
relaysCount: number;
relayStatus?: Array<{ endpoint: string; ok: boolean; error?: string }>;
} | null>(null);
const [publishRelayStatus, setPublishRelayStatus] = useState<
Array<{ endpoint: string; ok: boolean; error?: string }>
>([]);
const [collectProgressState, setCollectProgressState] = useState<{
satisfied: number;
required: number;
} | null>(null);
const graphResolver = useState(() => new GraphResolver())[0];
const pairingSatisfied = isPairingSatisfied();
const relayOk = getStoredRelays().length > 0;
useEffect(() => {
if (!isInIframe()) {
return;
}
sendToChannel({
type: 'pairing-relay-status',
payload: {
pairingSatisfied,
relayOk,
},
});
}, [pairingSatisfied, relayOk]);
// Rediriger selon l'état de la machine à états. Si service+membre en URL (parent iframe),
// ne pas rediriger vers /services; dispatcher E_SELECT_* pour aller en S_LOGIN_BUILD_PATH.
useEffect(() => {
if (loginState === 'S_LOGIN_SELECT_SERVICE') {
if (serviceUuid !== '' && membreUuid !== '') {
dispatch({ type: 'E_SELECT_SERVICE', serviceUuid });
dispatch({ type: 'E_SELECT_MEMBER', membreUuid });
return;
}
navigate('/services');
return;
}
if (loginState === 'S_LOGIN_SELECT_MEMBER') {
if (serviceUuid !== '') {
navigate(`/select-member?service=${serviceUuid}`);
return;
}
navigate('/services');
return;
}
}, [loginState, serviceUuid, membreUuid, navigate, dispatch]);
const handleBuildPath = useCallback(async (): Promise<void> => {
if (serviceUuid === '' || membreUuid === '') {
handleError('Service UUID et Membre UUID requis', 'MISSING_PARAMS');
return;
}
setIsBuilding(true);
clearError();
dispatch({ type: 'E_SELECT_SERVICE', serviceUuid });
dispatch({ type: 'E_SELECT_MEMBER', membreUuid });
try {
const path = graphResolver.resolveLoginPath(serviceUuid, membreUuid);
if (path === null) {
handleError('Impossible de résoudre le chemin de login', 'PATH_RESOLUTION_FAILED');
dispatch({ type: 'E_PATH_INVALID' });
return;
}
setLoginPath(path);
if (path.statut === 'incomplet') {
handleError('Chemin incomplet. Synchronisez d\'abord les données.', 'INCOMPLETE_PATH');
dispatch({ type: 'E_PATH_INCOMPLETE' });
} else {
dispatch({ type: 'E_PATH_OK' });
dispatch({ type: 'E_PAIRS_OK' });
}
} catch (err) {
handleError(err, 'Erreur lors de la construction du chemin');
dispatch({ type: 'E_PATH_INVALID' });
} finally {
setIsBuilding(false);
}
}, [serviceUuid, membreUuid, graphResolver, handleError, clearError, dispatch]);
// Construire le chemin automatiquement si service et membre sont fournis
useEffect(() => {
if (
serviceUuid !== '' &&
membreUuid !== '' &&
loginPath === null &&
loginState === 'S_LOGIN_BUILD_PATH' &&
identity !== null
) {
void handleBuildPath();
}
}, [serviceUuid, membreUuid, loginPath, loginState, identity, handleBuildPath]);
const handleBuildChallenge = async (): Promise<void> => {
if (identity === null || loginPath === null) {
handleError('Identité ou chemin de login manquant', 'MISSING_REQUIREMENTS');
return;
}
if (loginPath.statut !== 'complet') {
handleError('Le chemin de login doit être complet avant de construire le challenge', 'INCOMPLETE_PATH');
return;
}
setIsBuilding(true);
clearError();
try {
const relays = getStoredRelays().map((r) => r.endpoint);
if (relays.length === 0) {
handleError('Aucun relais configuré', 'NO_RELAYS');
return;
}
const loginBuilder = new LoginBuilder(identity, relays);
const challenge = await loginBuilder.buildChallenge(
loginPath.service_uuid,
loginPath.action_login_uuid,
loginPath.action_login_uuid,
loginPath.membre_uuid,
);
const localPairs = getPairsForMember(loginPath.membre_uuid).filter(
(p) =>
p.is_local &&
loginPath.pairs_attendus.includes(p.uuid),
);
const pk = identity.privateKey;
if (pk === undefined) {
handleError('Clé privée indisponible. Déverrouillez l\'identité.', 'IDENTITY_LOCKED');
return;
}
const signatures: Array<{
signature: string;
cle_publique: string;
nonce: string;
pair_uuid: string;
}> = [];
for (const pair of localPairs) {
const sig = loginBuilder.signChallenge(challenge, pair.uuid, pk);
signatures.push({
signature: sig,
cle_publique: identity.publicKey,
nonce: challenge.nonce,
pair_uuid: pair.uuid,
});
}
if (signatures.length === 0) {
handleError(
'Aucun pair local pour ce membre. Signez sur ce device ou ajoutez un pair.',
'NO_LOCAL_PAIRS',
);
return;
}
const newProof = await loginBuilder.buildProof(challenge, signatures);
setProof(newProof);
dispatch({ type: 'E_CHALLENGE_READY' });
dispatch({ type: 'E_SIGNATURES_COMPLETE' });
} catch (err) {
handleError(err, 'Erreur lors de la construction du challenge');
} finally {
setIsBuilding(false);
}
};
/**
* Publish login proof to relays.
* Order: message → signatures → keys (strict order as per specs).
* Then collect remote signatures if needed (mFA with 2 devices).
* Finally verify locally and send proof to parent if in iframe.
*/
const handlePublish = async (): Promise<void> => {
if (identity === null || proof === null) {
handleError('Identité ou preuve manquante', 'MISSING_REQUIREMENTS');
return;
}
// Anti-rejeu: vérifier timestamp dans la fenêtre
if (!verifyTimestamp(proof.challenge.timestamp)) {
handleError(
'Timestamp hors fenêtre de validité (anti-rejeu)',
'X_TIMESTAMP_OUT_OF_WINDOW',
);
return;
}
// Anti-rejeu: vérifier nonce unique
await nonceStore.init();
if (nonceStore.hasUsed(proof.challenge.nonce)) {
handleError('Nonce déjà utilisé (anti-rejeu)', 'X_NONCE_REUSED');
return;
}
setIsPublishing(true);
clearError();
try {
const relays = getStoredRelays().filter((r) => r.enabled);
if (relays.length === 0) {
handleError('Aucun relais activé', 'NO_ENABLED_RELAYS');
return;
}
const loginBuilder = new LoginBuilder(identity, relays.map((r) => r.endpoint));
const msgChiffre = loginBuilder.challengeToMsgChiffre(proof.challenge);
const msgCle = loginBuilder.challengeToMsgCle(proof.challenge);
// Publication en ordre strict : message → signatures → clés
const publishResult = await publishMessageAndSigs(
relays,
msgChiffre,
proof.signatures,
msgCle,
);
setPublishRelayStatus(publishResult.relayStatus);
if (publishResult.successCount === 0) {
handleError('Échec de la publication sur tous les relais', 'PUBLISH_FAILED');
return;
}
// Collecte des signatures distantes (mFA avec 2 devices)
let merged = proof.signatures;
if (loginPath !== null) {
setIsCollecting(true);
setCollectProgressState(null);
try {
const pairToMembers = buildPairToMembers(loginPath.pairs_attendus);
const pubkeyToPair = buildPubkeyToPair(
identity.publicKey,
loginPath.pairs_attendus,
);
const endpoints = relays.map((r) => r.endpoint);
// Boucle de collecte : fetch signatures par hash jusqu'à satisfaction ou timeout
merged = await runCollectLoop({
relayEndpoints: endpoints,
hash: proof.challenge.hash,
ourSigs: proof.signatures,
path: loginPath,
pairToMembers,
pubkeyToPair,
opts: {
pollMs: COLLECT_POLL_MS,
timeoutMs: COLLECT_TIMEOUT_MS,
onProgress: (m) => {
const p = collectProgress(loginPath, m, pairToMembers);
setCollectProgressState({
satisfied: p.satisfied,
required: p.required,
});
},
},
});
} finally {
setIsCollecting(false);
setCollectProgressState(null);
}
}
// Si des signatures distantes ont été collectées, demander confirmation utilisateur
if (
loginPath !== null &&
hasRemoteSignatures(merged, loginPath.pairs_attendus)
) {
setCollectedMerged(merged);
setCollectedPublishStats({
successCount: publishResult.successCount,
relaysCount: relays.length,
relayStatus: publishResult.relayStatus,
});
setAwaitingRemoteAccept(true);
setIsPublishing(false);
return;
}
const finalProof = await loginBuilder.buildProof(proof.challenge, merged);
// Vérification locale finale (avant envoi au parent)
// Vérifie: hash, signatures (clés autorisées), dépendances, graphe, anti-rejeu
const verificationResults: string[] = [];
const verificationErrors: string[] = [];
let verificationSuccess = true;
// Vérifier hash
verificationResults.push(`✓ Hash: ${proof.challenge.hash.slice(0, 16)}...`);
// Vérifier signatures
if (loginPath !== null) {
const allowedPubkeys = buildAllowedPubkeys(loginPath, identity.publicKey);
if (allowedPubkeys.size > 0) {
const minimalMsg = {
hash: { hash_value: proof.challenge.hash },
} as unknown as MessageBase;
const sigs = merged.map((s) => ({
hash: proof.challenge.hash,
cle_publique: s.cle_publique,
signature: s.signature,
nonce: s.nonce,
}));
const { valid, unauthorized } = verifyMessageSignaturesStrict(
minimalMsg,
sigs,
allowedPubkeys,
);
if (valid.length > 0) {
verificationResults.push(`✓ Signatures valides: ${valid.length}`);
} else {
verificationErrors.push('Aucune signature valide');
verificationSuccess = false;
}
if (unauthorized.length > 0) {
verificationErrors.push(
`Signatures non autorisées: ${unauthorized.length} (clés: ${unauthorized.map((s) => s.cle_publique.slice(0, 16)).join(', ')}...)`,
);
verificationSuccess = false;
}
} else {
verificationErrors.push('Aucune clé publique autorisée trouvée dans les validateurs');
verificationSuccess = false;
}
// Vérifier dépendances
if (!checkDependenciesSatisfied(loginPath, finalProof)) {
verificationErrors.push('Dépendances entre signatures non satisfaites (membres requis manquants)');
verificationSuccess = false;
}
// Vérifier graphe
if (loginPath.statut === 'complet') {
verificationResults.push('✓ Graphe complet');
} else {
verificationErrors.push('Graphe incomplet - objets manquants dans le cache');
verificationSuccess = false;
}
}
// Vérifier anti-rejeu
verificationResults.push('✓ Nonce unique');
verificationResults.push('✓ Timestamp dans la fenêtre');
// Si vérification échoue, arrêter ici
if (!verificationSuccess) {
// Log structuré pour le débogage
const verificationLog = {
timestamp: new Date().toISOString(),
hash: proof.challenge.hash,
nonce: proof.challenge.nonce,
serviceUuid: loginPath?.service_uuid,
membreUuid: loginPath?.membre_uuid,
results: verificationResults,
errors: verificationErrors,
signaturesCount: merged.length,
loginPathStatus: loginPath?.statut,
};
console.error('[Login] Verification failed:', verificationLog);
handleError(
`Vérification locale échouée: ${verificationErrors.join('; ')}`,
'VERIFICATION_FAILED',
);
dispatch({ type: 'E_LOCAL_VERDICT_REJECT' });
return;
}
// Log structuré pour le succès
console.warn('[Login] Verification succeeded:', {
timestamp: new Date().toISOString(),
hash: proof.challenge.hash.slice(0, 16) + '...',
serviceUuid: loginPath?.service_uuid,
membreUuid: loginPath?.membre_uuid,
signaturesCount: merged.length,
});
await nonceStore.markUsed(proof.challenge.nonce, proof.challenge.timestamp);
const updatedProof = { ...finalProof, statut: 'publie' as const };
setProof(updatedProof);
sendLoginProof(updatedProof);
if (publishResult.successCount < relays.length) {
handleError(
`Publication partielle: ${publishResult.successCount}/${relays.length} relais`,
'PARTIAL_PUBLISH',
);
dispatch({ type: 'E_PUBLISH_LOGIN_PARTIAL' });
} else {
dispatch({ type: 'E_PUBLISH_LOGIN_OK' });
dispatch({ type: 'E_LOCAL_VERDICT_ACCEPT' });
}
} catch (err) {
handleError(err, 'Erreur lors de la publication');
} finally {
setIsPublishing(false);
}
};
if (identity === null) {
return (
<main>
<p>Identité requise pour se connecter</p>
<button type="button" onClick={(): void => navigate('/')}>
Retour
</button>
</main>
);
}
if (!isPairingSatisfied()) {
return (
<main>
<h1>Se connecter</h1>
<p>Pairing obligatoire (G_PAIRING_SATISFIED). Configurez au moins un pair.</p>
<div>
<button
type="button"
onClick={(): void => navigate('/manage-pairs')}
>
Configurer le pairing
</button>
<button type="button" onClick={(): void => navigate('/')}>
Retour
</button>
</div>
</main>
);
}
const handleBack = (): void => {
dispatch({ type: 'E_BACK' });
navigate('/');
};
const handleSyncNow = (): void => {
dispatch({ type: 'E_SYNC_NOW' });
navigate('/sync');
};
const handleAddPair = (): void => {
dispatch({ type: 'E_ADD_PAIR' });
navigate('/manage-pairs');
};
const handleRefuseRemote = (): void => {
dispatch({ type: 'E_LOCAL_VERDICT_REJECT' });
setAwaitingRemoteAccept(false);
setCollectedMerged(null);
setCollectedPublishStats(null);
};
const handleAcceptRemote = async (): Promise<void> => {
if (
identity === null ||
proof === null ||
loginPath === null ||
collectedMerged === null ||
collectedPublishStats === null
) {
return;
}
clearError();
const relays = getStoredRelays().filter((r) => r.enabled);
const loginBuilder = new LoginBuilder(
identity,
relays.map((r) => r.endpoint),
);
const finalProof = await loginBuilder.buildProof(
proof.challenge,
collectedMerged,
);
if (!checkDependenciesSatisfied(loginPath, finalProof)) {
handleError(
'Dépendances entre signatures non satisfaites (membres requis manquants)',
'DEPENDENCIES_UNSATISFIED',
);
return;
}
const allowedPubkeys = buildAllowedPubkeys(loginPath, identity.publicKey);
if (allowedPubkeys.size > 0) {
const minimalMsg = {
hash: { hash_value: proof.challenge.hash },
} as unknown as MessageBase;
const sigs = collectedMerged.map((s) => ({
hash: proof.challenge.hash,
cle_publique: s.cle_publique,
signature: s.signature,
nonce: s.nonce,
}));
const { valid, unauthorized } = verifyMessageSignaturesStrict(
minimalMsg,
sigs,
allowedPubkeys,
);
if (unauthorized.length > 0 || valid.length === 0) {
handleError(
'Signature(s) avec clé non autorisée par les validateurs (X_PUBKEY_NOT_AUTHORIZED)',
'X_PUBKEY_NOT_AUTHORIZED',
);
return;
}
}
await nonceStore.markUsed(
proof.challenge.nonce,
proof.challenge.timestamp,
);
const updatedProof = { ...finalProof, statut: 'publie' as const };
setProof(updatedProof);
sendLoginProof(updatedProof);
if (
collectedPublishStats.successCount < collectedPublishStats.relaysCount
) {
handleError(
`Publication partielle: ${collectedPublishStats.successCount}/${collectedPublishStats.relaysCount} relais`,
'PARTIAL_PUBLISH',
);
dispatch({ type: 'E_PUBLISH_LOGIN_PARTIAL' });
} else {
dispatch({ type: 'E_PUBLISH_LOGIN_OK' });
dispatch({ type: 'E_LOCAL_VERDICT_ACCEPT' });
}
setAwaitingRemoteAccept(false);
setCollectedMerged(null);
setCollectedPublishStats(null);
};
const showRecoveryActions =
(loginPath !== null && loginPath.statut === 'incomplet') ||
loginState === 'S_ERROR_RECOVERABLE';
const handleRetry = (): void => {
dispatch({ type: 'E_RETRY' });
if (serviceUuid !== '' && membreUuid !== '') {
void handleBuildPath();
}
};
return (
<main>
<h1>Se connecter</h1>
<p role="status" aria-live="polite" className="sr-only">
État: {loginState}
</p>
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
{(loginState === 'S_LOGIN_SELECT_SERVICE' ||
loginState === 'S_LOGIN_SELECT_MEMBER') && (
<section aria-labelledby="redirect-info">
<p>Redirection vers la sélection...</p>
</section>
)}
{loginPath !== null && (
<section aria-labelledby="login-path">
<h2 id="login-path">Chemin de login</h2>
<div>
<p>
<strong>Statut:</strong> {loginPath.statut}
</p>
{loginPath.contrat_version !== undefined && (
<p>
<strong>Version contrat:</strong> {loginPath.contrat_version}
</p>
)}
<div>
<h3>Résumé du chemin</h3>
<ul>
<li>
<strong>Service:</strong> {loginPath.service_uuid}
</li>
{loginPath.contrat_uuid.length > 0 && (
<li>
<strong>Contrat(s):</strong>{' '}
{loginPath.contrat_uuid.join(', ')}
</li>
)}
{loginPath.champ_uuid !== undefined &&
loginPath.champ_uuid.length > 0 && (
<li>
<strong>Champ(s):</strong>{' '}
{loginPath.champ_uuid.join(', ')}
</li>
)}
<li>
<strong>Action login:</strong> {loginPath.action_login_uuid}
</li>
<li>
<strong>Membre:</strong> {loginPath.membre_uuid}
</li>
<li>
<strong>Pairs attendus:</strong> {loginPath.pairs_attendus.length}
</li>
</ul>
</div>
{loginPath.signatures_requises.length > 0 && (
<div>
<h3>Signatures requises</h3>
<table>
<thead>
<tr>
<th>Membre UUID</th>
<th>Pair UUID</th>
<th>Clé publique</th>
<th>Cardinalité min.</th>
<th>Dépendances</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{loginPath.signatures_requises.map((req, idx) => {
// Déterminer le statut de cette signature
const proofSigs = proof?.signatures ?? [];
const collectedSigs = collectedMerged ?? [];
const allSigs = [...proofSigs, ...collectedSigs];
const hasSignature = req.cle_publique !== undefined &&
allSigs.some((s) => s.cle_publique === req.cle_publique);
const status = hasSignature ? 'reçue' : 'manquante';
return (
<tr key={idx}>
<td>{req.membre_uuid.slice(0, 8)}...</td>
<td>
{req.pair_uuid !== undefined
? `${req.pair_uuid.slice(0, 8)}...`
: '—'}
</td>
<td>
{req.cle_publique !== undefined
? `${req.cle_publique.slice(0, 16)}...`
: '—'}
</td>
<td>
{req.cardinalite_minimale ?? '1'}
</td>
<td>
{req.dependances !== undefined &&
req.dependances.length > 0
? req.dependances.join(', ')
: '—'}
</td>
<td>
{status === 'reçue' ? '✓ Reçue' : '✗ Manquante'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{loginPath.statut === 'complet' && (
<button
type="button"
onClick={handleBuildChallenge}
disabled={isBuilding}
>
{isBuilding ? 'Construction...' : 'Démarrer le login'}
</button>
)}
{loginPath.statut === 'incomplet' && showRecoveryActions && (
<div>
<button type="button" onClick={handleSyncNow}>
Synchroniser
</button>
<button type="button" onClick={handleAddPair}>
Ajouter un pair
</button>
</div>
)}
</div>
</section>
)}
{loginState === 'S_ERROR_RECOVERABLE' && (
<section aria-labelledby="error-recoverable">
<h2 id="error-recoverable">Erreur récupérable</h2>
<p>
Une erreur s&apos;est produite, mais elle peut être corrigée. Choisissez
une action :
</p>
<div>
<button type="button" onClick={handleRetry}>
Réessayer
</button>
<button type="button" onClick={handleSyncNow}>
Synchroniser maintenant
</button>
<button type="button" onClick={handleAddPair}>
Ajouter un pair
</button>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_OPEN_DIAGNOSTIC' });
navigate('/diagnostic');
}}
>
Diagnostic avancé
</button>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_BACK' });
navigate('/');
}}
>
Retour à l&apos;accueil
</button>
</div>
</section>
)}
{loginState === 'S_ERROR_FATAL' && (
<section aria-labelledby="error-fatal">
<h2 id="error-fatal">Erreur fatale</h2>
<p>
Une erreur bloquante s&apos;est produite. Impossible de continuer sans
intervention externe.
</p>
{error !== null && (
<div>
<p>
<strong>Erreur:</strong> {error.message}
</p>
{error.code !== undefined && (
<p>
<strong>Code:</strong> {error.code}
</p>
)}
</div>
)}
<div>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_EXIT' });
navigate('/');
}}
>
Quitter
</button>
</div>
</section>
)}
{showRecoveryActions && loginPath === null && loginState !== 'S_ERROR_RECOVERABLE' && (
<section aria-label="Reprise">
<button type="button" onClick={handleSyncNow}>
Synchroniser
</button>
<button type="button" onClick={handleAddPair}>
Ajouter un pair
</button>
</section>
)}
{proof !== null && (
<section aria-labelledby="login-proof">
<h2 id="login-proof">Message de login à valider</h2>
<div>
<div>
<h3>Résumé public</h3>
<ul>
<li>
<strong>Service:</strong> {loginPath?.service_uuid ?? 'N/A'}
</li>
<li>
<strong>Type:</strong> {proof.challenge.datajson_public.types_uuid.join(', ')}
</li>
<li>
<strong>Timestamp:</strong>{' '}
{new Date(proof.challenge.datajson_public.timestamp).toLocaleString()}
</li>
<li>
<strong>Relais:</strong> {proof.challenge.datajson_public.services_uuid.length}
</li>
</ul>
</div>
<div>
<h3>Détails techniques (mode avancé)</h3>
<p>
<strong>Hash:</strong> {proof.challenge.hash}
</p>
<p>
<strong>Nonce:</strong> {proof.challenge.nonce}
</p>
<p>
<strong>Statut:</strong> {proof.statut}
</p>
<p>
<strong>Signatures:</strong> {proof.signatures.length}
</p>
</div>
{proof.statut === 'en_attente' && !awaitingRemoteAccept && (
<div>
<button
type="button"
onClick={() => {
void handlePublish();
}}
disabled={isPublishing}
>
{isCollecting
? 'En attente des signatures des autres appareils…'
: isPublishing
? 'Publication…'
: 'Publier la preuve'}
</button>
{publishRelayStatus.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<h4>Statut par relais</h4>
<table>
<thead>
<tr>
<th>Relais</th>
<th>Statut</th>
<th>Erreur</th>
</tr>
</thead>
<tbody>
{publishRelayStatus.map((status, idx) => (
<tr key={idx}>
<td>{status.endpoint}</td>
<td>{status.ok ? '✓ OK' : '✗ Échec'}</td>
<td>{status.error ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{isCollecting && proof !== null && (
<LoginCollectShare
proof={proof}
loginPath={loginPath}
collectProgress={collectProgressState}
collectedSignatures={collectedMerged ?? undefined}
onRefresh={() => {
void (async (): Promise<void> => {
if (loginPath === null || proof === null) {
return;
}
setIsCollecting(true);
try {
const relays = getStoredRelays().filter((r) => r.enabled);
const endpoints = relays.map((r) => r.endpoint);
const pairToMembers = buildPairToMembers(loginPath.pairs_attendus);
const pubkeyToPair = buildPubkeyToPair(
identity?.publicKey ?? '',
loginPath.pairs_attendus,
);
const merged = await runCollectLoop({
relayEndpoints: endpoints,
hash: proof.challenge.hash,
ourSigs: proof.signatures,
path: loginPath,
pairToMembers,
pubkeyToPair,
opts: {
pollMs: COLLECT_POLL_MS,
timeoutMs: COLLECT_TIMEOUT_MS,
onProgress: (m) => {
const p = collectProgress(loginPath, m, pairToMembers);
setCollectProgressState({
satisfied: p.satisfied,
required: p.required,
});
},
},
});
setCollectedMerged(merged);
} finally {
setIsCollecting(false);
}
})();
}}
onViewDetails={(requirement, pairUuid) => {
// Afficher les détails de la signature dans une alerte ou un modal
const detail = `Membre: ${requirement}\nPair: ${pairUuid ?? 'N/A'}\nHash: ${proof.challenge.hash.slice(0, 16)}...\nNonce: ${proof.challenge.nonce.slice(0, 16)}...`;
alert(detail);
}}
/>
)}
{awaitingRemoteAccept &&
collectedMerged !== null &&
proof !== null &&
loginPath !== null && (
<section
aria-labelledby="confirm-remote-sigs"
style={{ marginTop: '1rem' }}
>
<h3 id="confirm-remote-sigs">
Confirmer les signatures du 2 appareil
</h3>
<p>
Des signatures ont é reçues du 2 appareil. Les mots ont
pu être visibles à l&apos;écran et interceptés par une
tierce personne. Confirmer que c&apos;est bien vous qui avez
validé sur l&apos;autre appareil ?
</p>
<div>
<button
type="button"
onClick={(): void => void handleAcceptRemote()}
>
Accepter
</button>
<button
type="button"
onClick={handleRefuseRemote}
>
Refuser
</button>
</div>
</section>
)}
</div>
</section>
)}
{loginState === 'S_LOGIN_SUCCESS' && (
<section aria-labelledby="login-success">
<h2 id="login-success">Login réussi</h2>
<p>La preuve de login a é acceptée et la session est ouverte.</p>
<div>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_DONE' });
navigate('/');
}}
>
Terminer
</button>
</div>
</section>
)}
{loginState === 'S_LOGIN_FAILURE' && (
<section aria-labelledby="login-failure">
<h2 id="login-failure">Login échoué</h2>
<p>La vérification locale a échoué. Diagnostics :</p>
{error !== null && (
<div>
<p>
<strong>Erreur:</strong> {error.message}
</p>
{error.code !== undefined && (
<p>
<strong>Code:</strong> {error.code}
</p>
)}
</div>
)}
<div>
<h3>Actions possibles :</h3>
<ul>
<li>
<strong>Signatures manquantes :</strong> Vérifiez que tous les pairs
requis ont signé. Utilisez &quot;Rafraîchir&quot; dans la collecte de
signatures.
</li>
<li>
<strong>Objets manquants :</strong> Synchronisez les données pour
récupérer les contrats, membres, pairs manquants.
</li>
<li>
<strong>Anti-rejeu :</strong> Le nonce a peut-être é réutilisé. Un
nouveau login générera un nouveau nonce.
</li>
<li>
<strong>Clés non autorisées :</strong> Vérifiez que les clés publiques
des signatures correspondent aux validateurs du contrat.
</li>
</ul>
</div>
<div>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_RETRY' });
if (serviceUuid !== '' && membreUuid !== '') {
void handleBuildPath();
}
}}
>
Réessayer
</button>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_RESYNC' });
navigate('/sync');
}}
>
Resync
</button>
<button
type="button"
onClick={(): void => {
dispatch({ type: 'E_BACK' });
navigate('/');
}}
>
Retour à l&apos;accueil
</button>
</div>
</section>
)}
<div>
<button
type="button"
onClick={handleBack}
disabled={awaitingRemoteAccept || loginState === 'S_LOGIN_SUCCESS' || loginState === 'S_LOGIN_FAILURE'}
>
Retour
</button>
</div>
</main>
);
}