UserWallet login flow, website-skeleton contract verify, data init/migrate

**Motivations:**
- Parcours login contrat: sélection service → membre → build path → proof, vérification côté parent (service-login-verify)
- Scripts data: initialisation SQLite (utxos, anchors, fees), migration depuis fichiers, sync UTXOs
- Website-skeleton: intégration contrat, extraction validateurs login, vérification preuve

**Root causes:**
- N/A (évolutions)

**Correctifs:**
- N/A

**Evolutions:**
- UserWallet: MemberSelectionScreen, LoginScreen machine à états (S_LOGIN_SELECT_SERVICE → S_LOGIN_SELECT_MEMBER → build path), service/membre via query params, useChannel/iframeChannel
- Website-skeleton: contract.ts (extractLoginValidators, isValidContract, isValidAction), main.ts validateurs depuis contrat, config étendu
- Data: init-db.js (tables utxos/anchors/fees), migrate-from-files.js

**Pages affectées:**
- data/init-db.js, data/migrate-from-files.js, data/sync-utxos.log
- userwallet: App, LoginCollectShare, LoginScreen, MemberSelectionScreen, ServiceListScreen, useChannel, iframeChannel
- website-skeleton: README, config, contract, main
This commit is contained in:
ncantu 2026-01-28 01:20:43 +01:00
parent 6bf37be44e
commit 8208809f03
14 changed files with 1358 additions and 166 deletions

95
data/init-db.js Normal file
View File

@ -0,0 +1,95 @@
/**
* Script d'initialisation de la base de données SQLite
* Crée les tables et les index nécessaires
*/
import Database from 'better-sqlite3';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, 'signet.db');
const db = new Database(dbPath);
// Activer les clés étrangères
db.pragma('foreign_keys = ON');
// Table utxos
db.exec(`
CREATE TABLE IF NOT EXISTS utxos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
txid TEXT NOT NULL,
vout INTEGER NOT NULL,
address TEXT,
amount REAL NOT NULL,
confirmations INTEGER DEFAULT 0,
is_anchor_change BOOLEAN DEFAULT FALSE,
block_time INTEGER,
is_spent_onchain BOOLEAN DEFAULT FALSE,
is_locked_in_mutex BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(txid, vout)
);
CREATE INDEX IF NOT EXISTS idx_utxos_category ON utxos(category);
CREATE INDEX IF NOT EXISTS idx_utxos_txid_vout ON utxos(txid, vout);
CREATE INDEX IF NOT EXISTS idx_utxos_confirmations ON utxos(confirmations);
CREATE INDEX IF NOT EXISTS idx_utxos_amount ON utxos(amount);
CREATE INDEX IF NOT EXISTS idx_utxos_is_spent ON utxos(is_spent_onchain);
`);
// Table anchors (hash_list.txt)
db.exec(`
CREATE TABLE IF NOT EXISTS anchors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT NOT NULL UNIQUE,
txid TEXT NOT NULL,
block_height INTEGER,
confirmations INTEGER DEFAULT 0,
date TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_anchors_hash ON anchors(hash);
CREATE INDEX IF NOT EXISTS idx_anchors_txid ON anchors(txid);
CREATE INDEX IF NOT EXISTS idx_anchors_block_height ON anchors(block_height);
`);
// Table fees (fees_list.txt)
db.exec(`
CREATE TABLE IF NOT EXISTS fees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
txid TEXT NOT NULL UNIQUE,
fee REAL NOT NULL,
fee_sats INTEGER NOT NULL,
block_height INTEGER,
block_time INTEGER,
confirmations INTEGER DEFAULT 0,
change_address TEXT,
change_amount REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_fees_txid ON fees(txid);
CREATE INDEX IF NOT EXISTS idx_fees_block_height ON fees(block_height);
`);
// Table cache pour suivre les mises à jour
db.exec(`
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
console.log('✅ Base de données initialisée avec succès');
console.log(`📁 Fichier: ${dbPath}`);
db.close();

217
data/migrate-from-files.js Normal file
View File

@ -0,0 +1,217 @@
/**
* Script de migration des fichiers texte vers SQLite
*/
import Database from 'better-sqlite3';
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = join(__dirname, 'signet.db');
const db = new Database(dbPath);
// Activer les clés étrangères
db.pragma('foreign_keys = ON');
// Préparer les requêtes d'insertion
const insertUtxo = db.prepare(`
INSERT OR REPLACE INTO utxos
(category, txid, vout, amount, confirmations, is_anchor_change, block_time, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
const insertAnchor = db.prepare(`
INSERT OR REPLACE INTO anchors
(hash, txid, block_height, confirmations, date, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
const insertFee = db.prepare(`
INSERT OR REPLACE INTO fees
(txid, fee, fee_sats, block_height, block_time, confirmations, change_address, change_amount, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`);
const insertManyUtxos = db.transaction((utxos) => {
for (const utxo of utxos) {
insertUtxo.run(
utxo.category,
utxo.txid,
utxo.vout,
utxo.amount,
utxo.confirmations,
utxo.is_anchor_change ? 1 : 0,
utxo.block_time || null
);
}
});
const insertManyAnchors = db.transaction((anchors) => {
for (const anchor of anchors) {
insertAnchor.run(
anchor.hash,
anchor.txid,
anchor.block_height,
anchor.confirmations,
anchor.date
);
}
});
const insertManyFees = db.transaction((fees) => {
for (const fee of fees) {
insertFee.run(
fee.txid,
fee.fee,
fee.fee_sats,
fee.block_height,
fee.block_time,
fee.confirmations,
fee.change_address,
fee.change_amount
);
}
});
// Migrer utxo_list.txt
console.log('📦 Migration de utxo_list.txt...');
const utxoListPath = join(__dirname, '../utxo_list.txt');
if (existsSync(utxoListPath)) {
const content = readFileSync(utxoListPath, 'utf8').trim();
const lines = content.split('\n').filter(line => line.trim());
const utxos = [];
for (const line of lines) {
const parts = line.split(';');
if (parts.length >= 6) {
let category, txid, vout, amount, confirmations, isAnchorChange, blockTime;
if (parts.length === 7 && !isNaN(parseFloat(parts[3]))) {
// Nouveau format: category;txid;vout;amount;confirmations;isAnchorChange;blockTime
[category, txid, vout, amount, confirmations, isAnchorChange, blockTime] = parts;
} else if (parts.length >= 6) {
// Ancien format: category;txid;vout;address;amount;confirmations;isAnchorChange
[category, txid, vout, , amount, confirmations] = parts;
isAnchorChange = parts.length > 6 ? parts[6] === 'true' : false;
blockTime = parts.length > 7 ? parseInt(parts[7], 10) || null : null;
}
utxos.push({
category: category.trim(),
txid: txid.trim(),
vout: parseInt(vout, 10),
amount: parseFloat(amount),
confirmations: parseInt(confirmations, 10) || 0,
is_anchor_change: isAnchorChange === 'true' || isAnchorChange === true,
block_time: blockTime ? parseInt(blockTime, 10) : null
});
}
}
insertManyUtxos(utxos);
console.log(`${utxos.length} UTXOs migrés`);
} else {
console.log('⚠️ utxo_list.txt non trouvé');
}
// Migrer hash_list.txt
console.log('📦 Migration de hash_list.txt...');
const hashListPath = join(__dirname, '../hash_list.txt');
if (existsSync(hashListPath)) {
const content = readFileSync(hashListPath, 'utf8').trim();
const lines = content.split('\n').filter(line => line.trim());
const anchors = [];
for (const line of lines) {
const parts = line.split(';');
if (parts.length >= 2) {
const [hash, txid, blockHeight, confirmations, date] = parts;
anchors.push({
hash: hash.trim(),
txid: txid.trim(),
block_height: blockHeight ? parseInt(blockHeight, 10) : null,
confirmations: confirmations ? parseInt(confirmations, 10) : 0,
date: date || new Date().toISOString()
});
}
}
insertManyAnchors(anchors);
console.log(`${anchors.length} ancrages migrés`);
} else {
console.log('⚠️ hash_list.txt non trouvé');
}
// Migrer fees_list.txt
console.log('📦 Migration de fees_list.txt...');
const feesListPath = join(__dirname, '../fees_list.txt');
if (existsSync(feesListPath)) {
const content = readFileSync(feesListPath, 'utf8').trim();
const lines = content.split('\n').filter(line => line.trim());
const fees = [];
for (const line of lines) {
const parts = line.split(';');
// Format: txid;fee;fee_sats;blockHeight;blockTime;confirmations;changeAddress;changeAmount
if (parts.length >= 3) {
const [txid, fee, feeSats, blockHeight, blockTime, confirmations, changeAddress, changeAmount] = parts;
fees.push({
txid: txid.trim(),
fee: parseFloat(fee) || 0,
fee_sats: parseInt(feeSats, 10) || 0,
block_height: blockHeight ? parseInt(blockHeight, 10) : null,
block_time: blockTime ? parseInt(blockTime, 10) : null,
confirmations: confirmations ? parseInt(confirmations, 10) : 0,
change_address: changeAddress || null,
change_amount: changeAmount ? parseFloat(changeAmount) : null
});
}
}
insertManyFees(fees);
console.log(`${fees.length} frais migrés`);
} else {
console.log('⚠️ fees_list.txt non trouvé');
}
// Migrer les caches
console.log('📦 Migration des caches...');
const utxoCachePath = join(__dirname, '../utxo_list_cache.txt');
if (existsSync(utxoCachePath)) {
const cacheContent = readFileSync(utxoCachePath, 'utf8').trim();
const parts = cacheContent.split(';');
if (parts.length >= 2) {
const insertCache = db.prepare('INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)');
insertCache.run('utxo_list_cache', cacheContent);
console.log('✅ Cache UTXO migré');
}
}
const hashCachePath = join(__dirname, '../hash_list_cache.txt');
if (existsSync(hashCachePath)) {
const cacheContent = readFileSync(hashCachePath, 'utf8').trim();
const parts = cacheContent.split(';');
if (parts.length >= 2) {
const insertCache = db.prepare('INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)');
insertCache.run('hash_list_cache', cacheContent);
console.log('✅ Cache hash migré');
}
}
console.log('\n✅ Migration terminée avec succès!');
console.log(`📁 Base de données: ${dbPath}`);
// Afficher les statistiques
const utxoCount = db.prepare('SELECT COUNT(*) as count FROM utxos').get();
const anchorCount = db.prepare('SELECT COUNT(*) as count FROM anchors').get();
const feeCount = db.prepare('SELECT COUNT(*) as count FROM fees').get();
console.log('\n📊 Statistiques:');
console.log(` UTXOs: ${utxoCount.count}`);
console.log(` Ancrages: ${anchorCount.count}`);
console.log(` Frais: ${feeCount.count}`);
db.close();

View File

@ -1,43 +1,3 @@
⏳ Traitement: 100000/174934 UTXOs insérés...
⏳ Traitement: 110000/174934 UTXOs insérés...
⏳ Traitement: 120000/174934 UTXOs insérés...
⏳ Traitement: 130000/174934 UTXOs insérés...
⏳ Traitement: 140000/174934 UTXOs insérés...
⏳ Traitement: 150000/174934 UTXOs insérés...
⏳ Traitement: 160000/174934 UTXOs insérés...
⏳ Traitement: 170000/174934 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 67955
- UTXOs toujours disponibles: 67955
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 443
- Non dépensés: 67955
✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 66109
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 189710
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/189710 UTXOs insérés...
⏳ Traitement: 20000/189710 UTXOs insérés...
⏳ Traitement: 30000/189710 UTXOs insérés...
⏳ Traitement: 40000/189710 UTXOs insérés...
⏳ Traitement: 50000/189710 UTXOs insérés...
⏳ Traitement: 60000/189710 UTXOs insérés...
⏳ Traitement: 70000/189710 UTXOs insérés...
⏳ Traitement: 80000/189710 UTXOs insérés...
⏳ Traitement: 90000/189710 UTXOs insérés...
⏳ Traitement: 100000/189710 UTXOs insérés...
⏳ Traitement: 110000/189710 UTXOs insérés...
⏳ Traitement: 120000/189710 UTXOs insérés...
⏳ Traitement: 130000/189710 UTXOs insérés...
⏳ Traitement: 140000/189710 UTXOs insérés...
⏳ Traitement: 150000/189710 UTXOs insérés...
@ -98,3 +58,43 @@
- Non dépensés: 64639
✅ Synchronisation terminée
🔍 Démarrage de la synchronisation des UTXOs dépensés...
📊 UTXOs à vérifier: 64412
📡 Récupération des UTXOs depuis Bitcoin...
📊 UTXOs disponibles dans Bitcoin: 203310
💾 Création de la table temporaire...
💾 Insertion des UTXOs disponibles par batch...
⏳ Traitement: 10000/203310 UTXOs insérés...
⏳ Traitement: 20000/203310 UTXOs insérés...
⏳ Traitement: 30000/203310 UTXOs insérés...
⏳ Traitement: 40000/203310 UTXOs insérés...
⏳ Traitement: 50000/203310 UTXOs insérés...
⏳ Traitement: 60000/203310 UTXOs insérés...
⏳ Traitement: 70000/203310 UTXOs insérés...
⏳ Traitement: 80000/203310 UTXOs insérés...
⏳ Traitement: 90000/203310 UTXOs insérés...
⏳ Traitement: 100000/203310 UTXOs insérés...
⏳ Traitement: 110000/203310 UTXOs insérés...
⏳ Traitement: 120000/203310 UTXOs insérés...
⏳ Traitement: 130000/203310 UTXOs insérés...
⏳ Traitement: 140000/203310 UTXOs insérés...
⏳ Traitement: 150000/203310 UTXOs insérés...
⏳ Traitement: 160000/203310 UTXOs insérés...
⏳ Traitement: 170000/203310 UTXOs insérés...
⏳ Traitement: 180000/203310 UTXOs insérés...
⏳ Traitement: 190000/203310 UTXOs insérés...
⏳ Traitement: 200000/203310 UTXOs insérés...
💾 Mise à jour des UTXOs dépensés...
📊 Résumé:
- UTXOs vérifiés: 64412
- UTXOs toujours disponibles: 64412
- UTXOs dépensés détectés: 0
📈 Statistiques finales:
- Total UTXOs: 68398
- Dépensés: 3986
- Non dépensés: 64412
✅ Synchronisation terminée

View File

@ -16,6 +16,7 @@ import { SyncScreen } from './components/SyncScreen';
import { LoginScreen } from './components/LoginScreen';
import { LoginSignScreen } from './components/LoginSignScreen';
import { ServiceListScreen } from './components/ServiceListScreen';
import { MemberSelectionScreen } from './components/MemberSelectionScreen';
import { DataExportImportScreen } from './components/DataExportImportScreen';
import { UnlockScreen } from './components/UnlockScreen';
import { useChannel } from './hooks/useChannel';
@ -43,6 +44,7 @@ function AppContent(): JSX.Element {
<Route path="/relay-settings" element={<RelaySettingsScreen />} />
<Route path="/sync" element={<SyncScreen />} />
<Route path="/services" element={<ServiceListScreen />} />
<Route path="/select-member" element={<MemberSelectionScreen />} />
<Route path="/data" element={<DataExportImportScreen />} />
</Routes>
);

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import type { LoginProof } from '../types/identity';
import type { LoginProof, LoginPath } from '../types/identity';
const QR_SIZE = 200;
@ -29,17 +29,34 @@ function MaybeProgress(p: {
interface LoginCollectShareProps {
proof: LoginProof;
loginPath: LoginPath | null;
/** Progress from relay fetch (notifications). X/Y signatures. */
collectProgress?: { satisfied: number; required: number } | null;
/** Collected signatures from remote devices. */
collectedSignatures?: Array<{
signature: string;
cle_publique: string;
nonce: string;
pair_uuid: string;
}>;
/** Callback to refresh signatures from relays. */
onRefresh?: () => void;
/** Callback to view signature details. */
onViewDetails?: (requirement: string, pairUuid: string | undefined) => void;
}
/**
* Device 1: show link + QR for "Demander signature sur l'autre appareil" during collect.
* Displays progress (X/Y) when collectProgress provided.
* Shows detailed signature status when loginPath and collectedSignatures provided.
*/
export function LoginCollectShare({
proof,
loginPath,
collectProgress,
collectedSignatures,
onRefresh,
onViewDetails,
}: LoginCollectShareProps): JSX.Element {
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const url = buildLoginSignUrl(proof.challenge.hash, proof.challenge.nonce);
@ -52,24 +69,119 @@ export function LoginCollectShare({
});
}, [url]);
const getSignatureStatus = (): Array<{
requirement: string;
pairUuid: string | undefined;
clePublique: string | undefined;
status: 'manquante' | 'reçue' | 'valide' | 'invalide';
}> => {
if (loginPath === null) {
return [];
}
const collectedPubkeys = new Set(
collectedSignatures?.map((s) => s.cle_publique) ?? [],
);
const localSignatures = new Set(
proof.signatures.map((s) => s.cle_publique),
);
return loginPath.signatures_requises.map((req) => {
const hasCollected = req.cle_publique !== undefined &&
collectedPubkeys.has(req.cle_publique);
const hasLocal = req.cle_publique !== undefined &&
localSignatures.has(req.cle_publique);
let status: 'manquante' | 'reçue' | 'valide' | 'invalide';
if (hasLocal || hasCollected) {
status = 'reçue';
} else {
status = 'manquante';
}
return {
requirement: `${req.membre_uuid.slice(0, 8)}...`,
pairUuid: req.pair_uuid,
clePublique: req.cle_publique,
status,
};
});
};
const signatureStatuses = getSignatureStatus();
return (
<section aria-label={`Demander signature sur l${"'"}autre appareil`}>
<h3>{`Demander signature sur l${"'"}autre appareil`}</h3>
<section aria-label="Collecte signatures mFA">
<h3>Collecte signatures mFA</h3>
<MaybeProgress collectProgress={collectProgress} />
<p>Ouvrez ce lien ou scannez le QR sur le 2 appareil.</p>
<p>
<a href={url} target="_blank" rel="noopener noreferrer">
{url}
</a>
</p>
{qrDataUrl !== null && (
<img
src={qrDataUrl}
alt="QR code : lien pour signer le login sur le 2e appareil"
width={QR_SIZE}
height={QR_SIZE}
/>
{signatureStatuses.length > 0 && (
<div>
<h4>Signatures requises</h4>
{onRefresh !== undefined && (
<button type="button" onClick={onRefresh}>
Rafraîchir
</button>
)}
<table>
<thead>
<tr>
<th>Membre</th>
<th>Pair</th>
<th>Clé publique</th>
<th>État</th>
{onViewDetails !== undefined && <th>Actions</th>}
</tr>
</thead>
<tbody>
{signatureStatuses.map((sig, idx) => (
<tr key={idx}>
<td>{sig.requirement}</td>
<td>
{sig.pairUuid !== undefined
? `${sig.pairUuid.slice(0, 8)}...`
: '—'}
</td>
<td>
{sig.clePublique !== undefined
? `${sig.clePublique.slice(0, 16)}...`
: '—'}
</td>
<td>{sig.status}</td>
{onViewDetails !== undefined && (
<td>
<button
type="button"
onClick={() => {
onViewDetails(sig.requirement, sig.pairUuid);
}}
>
Voir détail
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
<div>
<h4>{`Demander signature sur l${"'"}autre appareil`}</h4>
<p>Ouvrez ce lien ou scannez le QR sur le 2 appareil.</p>
<p>
<a href={url} target="_blank" rel="noopener noreferrer">
{url}
</a>
</p>
{qrDataUrl !== null && (
<img
src={qrDataUrl}
alt="QR code : lien pour signer le login sur le 2e appareil"
width={QR_SIZE}
height={QR_SIZE}
/>
)}
</div>
</section>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useIdentity } from '../hooks/useIdentity';
import { useErrorHandler } from '../hooks/useErrorHandler';
@ -40,8 +40,8 @@ export function LoginScreen(): JSX.Element {
const { error, handleError, clearError } = useErrorHandler();
const { sendLoginProof } = useChannel();
const { state: loginState, dispatch } = useLoginStateMachine();
const [serviceUuid, setServiceUuid] = useState(searchParams.get('service') ?? '');
const [membreUuid, setMembreUuid] = useState('');
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);
@ -60,16 +60,25 @@ export function LoginScreen(): JSX.Element {
required: number;
} | null>(null);
const graphResolver = new GraphResolver();
const graphResolver = useState(() => new GraphResolver())[0];
// Rediriger selon l'état de la machine à états
useEffect(() => {
const serviceParam = searchParams.get('service');
if (serviceParam !== null) {
setServiceUuid(serviceParam);
if (loginState === 'S_LOGIN_SELECT_SERVICE') {
navigate('/services');
return;
}
}, [searchParams]);
if (loginState === 'S_LOGIN_SELECT_MEMBER') {
if (serviceUuid !== '') {
navigate(`/select-member?service=${serviceUuid}`);
return;
}
navigate('/services');
return;
}
}, [loginState, serviceUuid, navigate]);
const handleBuildPath = async (): Promise<void> => {
const handleBuildPath = useCallback(async (): Promise<void> => {
if (serviceUuid === '' || membreUuid === '') {
handleError('Service UUID et Membre UUID requis', 'MISSING_PARAMS');
return;
@ -100,7 +109,20 @@ export function LoginScreen(): JSX.Element {
} 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) {
@ -274,14 +296,17 @@ export function LoginScreen(): JSX.Element {
}
const finalProof = await loginBuilder.buildProof(proof.challenge, merged);
// Vérification locale finale
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) {
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 = {
@ -298,14 +323,54 @@ export function LoginScreen(): JSX.Element {
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;
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) {
console.error('Login verification failed:', {
results: verificationResults,
errors: verificationErrors,
});
handleError(
`Vérification locale échouée: ${verificationErrors.join('; ')}`,
'VERIFICATION_FAILED',
);
dispatch({ type: 'E_LOCAL_VERDICT_REJECT' });
return;
}
await nonceStore.markUsed(proof.challenge.nonce, proof.challenge.timestamp);
@ -463,6 +528,13 @@ export function LoginScreen(): JSX.Element {
(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>
@ -470,44 +542,12 @@ export function LoginScreen(): JSX.Element {
État: {loginState}
</p>
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
<section aria-labelledby="service-selection">
<h2 id="service-selection">Sélection du service</h2>
<div>
<label htmlFor="service-uuid">
Service UUID
<input
id="service-uuid"
type="text"
value={serviceUuid}
onChange={(e) => {
setServiceUuid(e.target.value);
}}
placeholder="service-uuid"
/>
</label>
</div>
<div>
<label htmlFor="membre-uuid">
Membre UUID
<input
id="membre-uuid"
type="text"
value={membreUuid}
onChange={(e) => {
setMembreUuid(e.target.value);
}}
placeholder="membre-uuid"
/>
</label>
</div>
<button
type="button"
onClick={handleBuildPath}
disabled={isBuilding}
>
{isBuilding ? 'Construction...' : 'Construire le chemin'}
</button>
</section>
{(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>
@ -520,28 +560,87 @@ export function LoginScreen(): JSX.Element {
<strong>Version contrat:</strong> {loginPath.contrat_version}
</p>
)}
<p>
<strong>Service:</strong> {loginPath.service_uuid}
</p>
<p>
<strong>Action login:</strong> {loginPath.action_login_uuid}
</p>
<p>
<strong>Membre:</strong> {loginPath.membre_uuid}
</p>
<p>
<strong>Pairs attendus:</strong> {loginPath.pairs_attendus.length}
</p>
<p>
<strong>Signatures requises:</strong> {loginPath.signatures_requises.length}
</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>
</tr>
</thead>
<tbody>
{loginPath.signatures_requises.map((req, idx) => (
<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 !== undefined
? req.cardinalite_minimale
: '1'}
</td>
<td>
{req.dependances !== undefined &&
req.dependances.length > 0
? req.dependances.join(', ')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{loginPath.statut === 'complet' && (
<button
type="button"
onClick={handleBuildChallenge}
disabled={isBuilding}
>
{isBuilding ? 'Construction...' : 'Construire le challenge'}
{isBuilding ? 'Construction...' : 'Démarrer le login'}
</button>
)}
{loginPath.statut === 'incomplet' && showRecoveryActions && (
@ -557,7 +656,37 @@ export function LoginScreen(): JSX.Element {
</div>
</section>
)}
{showRecoveryActions && loginPath === null && (
{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_BACK' });
navigate('/');
}}
>
Retour à l&apos;accueil
</button>
</div>
</section>
)}
{showRecoveryActions && loginPath === null && loginState !== 'S_ERROR_RECOVERABLE' && (
<section aria-label="Reprise">
<button type="button" onClick={handleSyncNow}>
Synchroniser
@ -569,17 +698,41 @@ export function LoginScreen(): JSX.Element {
)}
{proof !== null && (
<section aria-labelledby="login-proof">
<h2 id="login-proof">Preuve de login</h2>
<h2 id="login-proof">Message de login à valider</h2>
<div>
<p>
<strong>Statut:</strong> {proof.statut}
</p>
<p>
<strong>Hash:</strong> {proof.challenge.hash.slice(0, 16)}...
</p>
<p>
<strong>Signatures:</strong> {proof.signatures.length}
</p>
<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 && (
<button
type="button"
@ -596,7 +749,51 @@ export function LoginScreen(): JSX.Element {
{isCollecting && proof !== null && (
<LoginCollectShare
proof={proof}
loginPath={loginPath}
collectProgress={collectProgressState}
collectedSignatures={collectedMerged ?? undefined}
onRefresh={async () => {
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(
endpoints,
proof.challenge.hash,
proof.signatures,
loginPath,
pairToMembers,
pubkeyToPair,
{
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 !== undefined ? pairUuid : 'N/A'}\nHash: ${proof.challenge.hash.slice(0, 16)}...\nNonce: ${proof.challenge.nonce.slice(0, 16)}...`;
alert(detail);
}}
/>
)}
{awaitingRemoteAccept &&
@ -635,11 +832,95 @@ export function LoginScreen(): JSX.Element {
</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={handleSyncNow}>
Synchroniser
</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}
disabled={awaitingRemoteAccept || loginState === 'S_LOGIN_SUCCESS' || loginState === 'S_LOGIN_FAILURE'}
>
Retour
</button>

View File

@ -0,0 +1,216 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { GraphResolver } from '../services/graphResolver';
import { useIdentity } from '../hooks/useIdentity';
import { useErrorHandler } from '../hooks/useErrorHandler';
import { useLoginStateMachine } from '../hooks/useLoginStateMachine';
import { ErrorDisplay } from './ErrorDisplay';
import { getPairsForMember } from '../utils/pairing';
import type { Membre, Action } from '../types/contract';
import type { PairConfig } from '../types/identity';
interface MemberWithDetails {
membre: Membre;
actionsLogin: Action[];
pairs: PairConfig[];
hasValidLoginPath: boolean;
}
export function MemberSelectionScreen(): JSX.Element {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { identity } = useIdentity();
const { error, handleError, clearError } = useErrorHandler();
const { state: loginState, dispatch } = useLoginStateMachine();
const [members, setMembers] = useState<MemberWithDetails[]>([]);
const [isLoading, setIsLoading] = useState(false);
const serviceUuid = searchParams.get('service') ?? '';
useEffect(() => {
if (serviceUuid === '') {
handleError('Service UUID requis', 'MISSING_SERVICE');
return;
}
loadMembers();
}, [serviceUuid]);
const loadMembers = async (): Promise<void> => {
if (identity === null || serviceUuid === '') {
return;
}
setIsLoading(true);
clearError();
try {
const graphResolver = new GraphResolver();
const allMembres = graphResolver.getMembres();
const allActions = graphResolver.getActions();
// Filter membres that belong to this service
const serviceMembres = allMembres.filter((m) =>
m.datajson.services_uuid.includes(serviceUuid),
);
// Find login actions
const loginActions = allActions.filter((action) => {
const typesNames = action.types.types_names_chiffres ?? '';
return typesNames.includes('login');
});
const membersWithDetails: MemberWithDetails[] = [];
for (const membre of serviceMembres) {
// Find login actions for this member
const memberLoginActions = loginActions.filter((action) =>
membre.actions_parents_uuid.includes(action.uuid),
);
// Get pairs for this member
const pairs = getPairsForMember(membre.uuid);
// Check if login path is valid
const path = graphResolver.resolveLoginPath(serviceUuid, membre.uuid);
const hasValidLoginPath = path?.statut === 'complet';
membersWithDetails.push({
membre,
actionsLogin: memberLoginActions,
pairs,
hasValidLoginPath,
});
}
if (membersWithDetails.length === 0) {
handleError(
'Aucun membre avec action login trouvé pour ce service',
'NO_MEMBERS_WITH_LOGIN',
);
}
setMembers(membersWithDetails);
} catch (err) {
handleError(err, 'Erreur lors du chargement des membres');
} finally {
setIsLoading(false);
}
};
const handleSelectMember = (membreUuid: string): void => {
if (loginState === 'S_LOGIN_SELECT_MEMBER') {
dispatch({ type: 'E_SELECT_MEMBER', membreUuid });
}
navigate(`/login?service=${serviceUuid}&membre=${membreUuid}`);
};
const handleBack = (): void => {
if (loginState === 'S_LOGIN_SELECT_MEMBER') {
dispatch({ type: 'E_BACK' });
}
navigate('/services');
};
if (serviceUuid === '') {
return (
<main>
<h1>Sélection du membre</h1>
<p>Service UUID requis</p>
<button type="button" onClick={handleBack}>
Retour
</button>
</main>
);
}
return (
<main>
<h1>Sélection du membre</h1>
<p role="status" aria-live="polite" className="sr-only">
Service: {serviceUuid}
</p>
{error !== null && <ErrorDisplay error={error} onDismiss={clearError} />}
<section aria-labelledby="members-list">
<h2 id="members-list">Membres disponibles</h2>
{isLoading ? (
<p>Chargement...</p>
) : members.length === 0 ? (
<p>
Aucun membre avec action login trouvé pour ce service. Synchronisez
d&apos;abord les données.
</p>
) : (
<ul role="list">
{members.map((memberDetail) => {
const { membre, actionsLogin, pairs, hasValidLoginPath } =
memberDetail;
return (
<li key={membre.uuid}>
<div>
<h3>
{membre.datajson.label ?? `Membre ${membre.uuid.slice(0, 8)}`}
</h3>
<p>
<strong>UUID:</strong> {membre.uuid}
</p>
{membre.datajson.description_courte !== undefined && (
<p>{membre.datajson.description_courte}</p>
)}
<p>
<strong>Actions login:</strong> {actionsLogin.length}
</p>
{actionsLogin.length > 0 && (
<ul>
{actionsLogin.map((action) => (
<li key={action.uuid}>
Action: {action.uuid.slice(0, 8)}...
</li>
))}
</ul>
)}
<p>
<strong>Pairs associés:</strong> {pairs.length}
</p>
{pairs.length > 0 && (
<ul>
{pairs.map((pair) => (
<li key={pair.uuid}>
{pair.is_local ? 'Local' : 'Distant'} -{' '}
{pair.can_sign ? 'Peut signer' : 'Ne peut pas signer'}
</li>
))}
</ul>
)}
<p>
<strong>Chemin login:</strong>{' '}
{hasValidLoginPath ? 'Complet et valide' : 'Incomplet'}
</p>
<button
type="button"
onClick={() => {
handleSelectMember(membre.uuid);
}}
disabled={!hasValidLoginPath || actionsLogin.length === 0}
>
Sélectionner ce membre
</button>
</div>
</li>
);
})}
</ul>
)}
</section>
<div>
<button type="button" onClick={handleBack}>
Retour
</button>
<button
type="button"
onClick={loadMembers}
disabled={isLoading}
>
{isLoading ? 'Chargement...' : 'Rafraîchir'}
</button>
</div>
</main>
);
}

View File

@ -1,20 +1,33 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { GraphResolver } from '../services/graphResolver';
import { SyncService } from '../services/syncService';
import { useIdentity } from '../hooks/useIdentity';
import { useErrorHandler } from '../hooks/useErrorHandler';
import { useLoginStateMachine } from '../hooks/useLoginStateMachine';
import { ErrorDisplay } from './ErrorDisplay';
import { getStoredRelays } from '../utils/relay';
import type { ServiceStatus } from '../types/identity';
export function ServiceListScreen(): JSX.Element {
const navigate = useNavigate();
const location = useLocation();
const { identity } = useIdentity();
const { error, handleError, clearError } = useErrorHandler();
const { state: loginState, dispatch } = useLoginStateMachine();
const [services, setServices] = useState<ServiceStatus[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Si on vient du flux de login, s'assurer qu'on est dans le bon état
useEffect(() => {
if (location.pathname === '/services' && loginState === 'S_LOGIN_SELECT_SERVICE') {
// On est déjà dans le bon état
} else if (location.pathname === '/services' && loginState.startsWith('S_LOGIN_')) {
// Forcer l'état si on vient du flux login
dispatch({ type: 'E_BACK' });
}
}, [location.pathname, loginState, dispatch]);
useEffect(() => {
loadServices();
}, []);
@ -76,7 +89,10 @@ export function ServiceListScreen(): JSX.Element {
};
const handleSelectService = (serviceUuid: string): void => {
navigate(`/login?service=${serviceUuid}`);
if (loginState === 'S_LOGIN_SELECT_SERVICE') {
dispatch({ type: 'E_SELECT_SERVICE', serviceUuid });
}
navigate(`/select-member?service=${serviceUuid}`);
};
return (

View File

@ -1,17 +1,22 @@
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useState } from 'react';
import {
listenToChannel,
sendToChannel,
isInIframe,
type ChannelMessage,
type AuthRequestMessage,
type ContractMessage,
} from '../utils/iframeChannel';
import { useIdentity } from './useIdentity';
import { signMessage, generateChallenge } from '../utils/crypto';
import { GraphResolver } from '../services/graphResolver';
import { updateGraphFromMessage } from '../services/syncUpdateGraph';
import type { LoginProof } from '../types/identity';
import type { Contrat, Action } from '../types/contract';
export function useChannel() {
const { identity } = useIdentity();
const graphResolver = useState(() => new GraphResolver())[0];
const handleAuthRequest = useCallback(
(_message: AuthRequestMessage): void => {
@ -44,6 +49,46 @@ export function useChannel() {
[identity],
);
const handleContract = useCallback(
(message: ContractMessage): void => {
const payload = message.payload;
if (payload?.contrat !== undefined) {
// Valider et ajouter le contrat principal
try {
const contrat = payload.contrat as Contrat;
updateGraphFromMessage(contrat, graphResolver);
} catch (err) {
console.error('Error processing contract from channel:', err);
}
}
// Ajouter les contrats fils
if (Array.isArray(payload?.contrats_fils)) {
for (const cf of payload.contrats_fils) {
try {
const contrat = cf as Contrat;
updateGraphFromMessage(contrat, graphResolver);
} catch (err) {
console.error('Error processing child contract from channel:', err);
}
}
}
// Ajouter les actions
if (Array.isArray(payload?.actions)) {
for (const a of payload.actions) {
try {
const action = a as Action;
updateGraphFromMessage(action, graphResolver);
} catch (err) {
console.error('Error processing action from channel:', err);
}
}
}
},
[graphResolver],
);
const sendLoginProof = useCallback((proof: LoginProof): void => {
sendToChannel({
type: 'login-proof',
@ -59,11 +104,13 @@ export function useChannel() {
const cleanup = listenToChannel((message: ChannelMessage) => {
if (message.type === 'auth-request') {
handleAuthRequest(message as AuthRequestMessage);
} else if (message.type === 'contract') {
handleContract(message as ContractMessage);
}
});
return cleanup;
}, [handleAuthRequest]);
}, [handleAuthRequest, handleContract]);
return {
sendLoginProof,

View File

@ -5,7 +5,7 @@ import type { LoginProof } from '../types/identity';
* Messages pour la communication iframe avec Channel Messages.
*/
export interface ChannelMessage {
type: 'auth-request' | 'auth-response' | 'login-proof' | 'service-status' | 'error';
type: 'auth-request' | 'auth-response' | 'login-proof' | 'service-status' | 'error' | 'contract';
payload?: unknown;
}
@ -45,6 +45,15 @@ export interface ErrorMessage extends ChannelMessage {
};
}
export interface ContractMessage extends ChannelMessage {
type: 'contract';
payload: {
contrat?: unknown;
contrats_fils?: unknown[];
actions?: unknown[];
};
}
/**
* Send message to parent window (Channel Messages).
*/

View File

@ -1,11 +1,11 @@
# website-skeleton
Squelette dun site qui intègre UserWallet en iframe : écoute des messages `postMessage` (auth-request, login-proof, error), vérification des preuves de login via `service-login-verify`, et affichage du statut (accepté / refusé).
Squelette d'un site qui intègre UserWallet en iframe : écoute des messages `postMessage` (auth-request, login-proof, error, contract), vérification des preuves de login via `service-login-verify`, et affichage du statut (accepté / refusé).
## Prérequis
- **service-login-verify** : `npm run build` dans `../service-login-verify` avant dinstaller ou builder le skeleton.
- **UserWallet** : à servir sur lURL configurée (voir cidessous) pour que liframe fonctionne.
- **service-login-verify** : `npm run build` dans `../service-login-verify` avant d'installer ou builder le skeleton.
- **UserWallet** : à servir sur l'URL configurée (voir cidessous) pour que l'iframe fonctionne.
## Installation
@ -21,26 +21,56 @@ npm run build
npm run dev
```
Ouvre par défaut sur `http://localhost:3024`. Liframe pointe vers UserWallet (`USERWALLET_ORIGIN`).
Ouvre par défaut sur `http://localhost:3024`. L'iframe pointe vers UserWallet (`USERWALLET_ORIGIN`).
## Configuration
- **Origine UserWallet** : `src/config.ts` définit `USERWALLET_ORIGIN`. En dev, défaut `http://localhost:3018` (si UserWallet tourne en dev sur ce port). En prod, défaut `https://userwallet.certificator.4nkweb.com`. Pour override : variable denvironnement `VITE_USERWALLET_ORIGIN` (ex. `VITE_USERWALLET_ORIGIN=http://localhost:3018 npm run dev`).
- **Validateurs** : `DEFAULT_VALIDATEURS` dans `src/config.ts` est un placeholder. Remplacezle par les validateurs de laction login de votre contrat (ou chargezles depuis votre API / contrat fourni par channel).
- **Origine UserWallet** : `src/config.ts` définit `USERWALLET_ORIGIN`. En dev, défaut `http://localhost:3018` (si UserWallet tourne en dev sur ce port). En prod, défaut `https://userwallet.certificator.4nkweb.com`. Pour override : variable d'environnement `VITE_USERWALLET_ORIGIN` (ex. `VITE_USERWALLET_ORIGIN=http://localhost:3018 npm run dev`).
- **Validateurs** : `DEFAULT_VALIDATEURS` dans `src/config.ts` est un placeholder. Le skeleton charge dynamiquement les validateurs depuis les contrats reçus via channel messages (type `contract`). Si aucun contrat n'est reçu, les validateurs par défaut sont utilisés.
### Chargement dynamique des contrats
Le skeleton écoute les messages `postMessage` de type `contract` pour recevoir le contrat et mettre à jour les validateurs dynamiquement :
```javascript
// Exemple d'envoi de contrat depuis le parent
window.postMessage({
type: 'contract',
payload: {
contrat: {
uuid: '...',
validateurs: { membres_du_role: [...] },
datajson: { types_names_chiffres: 'contrat' }
},
contrats_fils: [...], // Optionnel
actions: [...] // Optionnel, pour extraire l'action login
}
}, '*');
```
Le skeleton :
1. Reçoit le message `contract`
2. Extrait les validateurs de l'action login (recherche dans `actions` pour un type contenant "login")
3. Met à jour les `allowedPubkeys` utilisés pour la vérification
4. Affiche un statut de confirmation
Si aucun contrat n'est reçu, les `DEFAULT_VALIDATEURS` sont utilisés comme fallback.
## Utilisation
1. Lancer UserWallet (dev ou déployé) sur lURL configurée.
1. Lancer UserWallet (dev ou déployé) sur l'URL configurée.
2. Lancer le skeleton (`npm run dev` ou servir `dist/`).
3. Ouvrir la page du skeleton : liframe affiche UserWallet.
4. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à liframe.
5. **Login** : depuis liframe, effectuer le flux de login ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ».
3. Ouvrir la page du skeleton : l'iframe affiche UserWallet.
4. **Envoyer contrat (optionnel)** : envoyer un message `contract` avec le contrat et ses actions pour mettre à jour les validateurs.
5. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à l'iframe.
6. **Login** : depuis l'iframe, effectuer le flux de login ; à la fin, UserWallet envoie `login-proof` au parent. Le skeleton vérifie la preuve (`verifyLoginProof`) et affiche « Login accepté » ou « Login refusé : … ».
## Structure
- `index.html` : page avec iframe, zone de statut, bouton auth.
- `src/main.ts` : chargement de liframe, écoute `message`, envoi `auth-request`, appel à `verifyLoginProof`, mise à jour du statut.
- `src/config.ts` : `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS`.
- `src/main.ts` : chargement de l'iframe, écoute `message`, envoi `auth-request`, appel à `verifyLoginProof`, mise à jour du statut, gestion des messages `contract`.
- `src/config.ts` : `USERWALLET_ORIGIN`, `DEFAULT_VALIDATEURS`, types `Contrat` et `Action`.
- `src/contract.ts` : extraction des validateurs depuis les contrats (`extractLoginValidators`), validation de structure (`isValidContract`, `isValidAction`).
## Déploiement

View File

@ -23,3 +23,47 @@ export const DEFAULT_VALIDATEURS = {
},
],
};
/**
* Contract structure (matches userwallet types).
*/
export interface Contrat {
uuid: string;
validateurs: {
membres_du_role: Array<{
membre_uuid: string;
signatures_obligatoires: Array<{
membre_uuid: string;
cle_publique?: string;
cardinalite_minimale?: number;
dependances?: string[];
}>;
}>;
};
datajson?: {
types_names_chiffres?: string;
[key: string]: unknown;
};
}
/**
* Action structure (matches userwallet types).
*/
export interface Action {
uuid: string;
validateurs_action: {
membres_du_role: Array<{
membre_uuid: string;
signatures_obligatoires: Array<{
membre_uuid: string;
cle_publique?: string;
cardinalite_minimale?: number;
dependances?: string[];
}>;
}>;
};
datajson?: {
types_names_chiffres?: string;
[key: string]: unknown;
};
}

View File

@ -0,0 +1,64 @@
import type { Validateurs } from 'service-login-verify';
import type { Contrat, Action } from './config.js';
/**
* Extract login action validators from contract and child contracts.
* Searches for action with types_names_chiffres containing "login".
*/
export function extractLoginValidators(
contrat: Contrat,
_contratsFils: Contrat[] = [],
actions: Action[] = [],
): Validateurs | null {
// Search for login action in provided actions
const loginAction = actions.find((action) => {
const typesNames = action.datajson?.types_names_chiffres ?? '';
return typesNames.includes('login');
});
if (loginAction?.validateurs_action !== undefined) {
return loginAction.validateurs_action;
}
// If no action provided, return contract validators as fallback
// (assuming contract validators are for login action)
if (contrat.validateurs !== undefined) {
return contrat.validateurs;
}
return null;
}
/**
* Check if a contract structure is valid.
*/
export function isValidContract(contrat: unknown): contrat is Contrat {
if (typeof contrat !== 'object' || contrat === null) {
return false;
}
const c = contrat as Record<string, unknown>;
return (
typeof c.uuid === 'string' &&
typeof c.validateurs === 'object' &&
c.validateurs !== null &&
Array.isArray((c.validateurs as { membres_du_role?: unknown }).membres_du_role)
);
}
/**
* Check if an action structure is valid.
*/
export function isValidAction(action: unknown): action is Action {
if (typeof action !== 'object' || action === null) {
return false;
}
const a = action as Record<string, unknown>;
return (
typeof a.uuid === 'string' &&
typeof a.validateurs_action === 'object' &&
a.validateurs_action !== null &&
Array.isArray(
(a.validateurs_action as { membres_du_role?: unknown }).membres_du_role,
)
);
}

View File

@ -5,15 +5,20 @@ import {
} from 'service-login-verify';
import type { LoginProof, Validateurs } from 'service-login-verify';
import { USERWALLET_ORIGIN, DEFAULT_VALIDATEURS } from './config.js';
import {
extractLoginValidators,
isValidContract,
isValidAction,
} from './contract.js';
import type { Contrat, Action } from './config.js';
const iframe = document.getElementById('userwallet') as HTMLIFrameElement;
const statusEl = document.getElementById('status') as HTMLParagraphElement;
const btnAuth = document.getElementById('btn-auth') as HTMLButtonElement;
const nonceCache = new NonceCache(3600000);
const allowedPubkeys = buildAllowedPubkeysFromValidateurs(
DEFAULT_VALIDATEURS as Validateurs,
);
let currentValidateurs: Validateurs = DEFAULT_VALIDATEURS as Validateurs;
let allowedPubkeys = buildAllowedPubkeysFromValidateurs(currentValidateurs);
function setStatus(text: string, kind: 'pending' | 'accepted' | 'rejected'): void {
statusEl.textContent = text;
@ -32,12 +37,63 @@ function sendAuthRequest(): void {
setStatus('Auth demandé (auth-request envoyé).', 'pending');
}
/**
* Update validators from contract and rebuild allowed pubkeys.
*/
function updateValidatorsFromContract(
contrat: Contrat,
contratsFils: Contrat[] = [],
actions: Action[] = [],
): void {
const validateurs = extractLoginValidators(contrat, contratsFils, actions);
if (validateurs !== null) {
currentValidateurs = validateurs;
allowedPubkeys = buildAllowedPubkeysFromValidateurs(validateurs);
setStatus('Contrat reçu et validateurs mis à jour.', 'pending');
} else {
setStatus(
'Contrat reçu mais validateurs login introuvables. Utilisation des validateurs par défaut.',
'pending',
);
}
}
function handleMessage(event: MessageEvent): void {
const d = event.data;
if (d?.type === undefined) {
return;
}
if (d.type === 'contract') {
const payload = d.payload as {
contrat?: unknown;
contrats_fils?: unknown[];
actions?: unknown[];
};
if (payload?.contrat !== undefined && isValidContract(payload.contrat)) {
const contratsFils: Contrat[] = [];
if (Array.isArray(payload.contrats_fils)) {
for (const cf of payload.contrats_fils) {
if (isValidContract(cf)) {
contratsFils.push(cf);
}
}
}
const actions: Action[] = [];
if (Array.isArray(payload.actions)) {
for (const a of payload.actions) {
if (isValidAction(a)) {
actions.push(a);
}
}
}
updateValidatorsFromContract(payload.contrat, contratsFils, actions);
} else {
setStatus('Message contrat reçu mais structure invalide.', 'rejected');
}
return;
}
if (d.type === 'login-proof') {
const proof = d.payload as LoginProof;
const result = verifyLoginProof(proof, {
@ -72,7 +128,10 @@ function init(): void {
iframe.src = USERWALLET_ORIGIN;
btnAuth?.addEventListener('click', sendAuthRequest);
window.addEventListener('message', handleMessage);
setStatus('En attente du login depuis liframe.', 'pending');
setStatus(
'En attente du login depuis liframe. Utilisation des validateurs par défaut jusquà réception dun contrat.',
'pending',
);
}
init();