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:
parent
6bf37be44e
commit
8208809f03
95
data/init-db.js
Normal file
95
data/init-db.js
Normal 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
217
data/migrate-from-files.js
Normal 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();
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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'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'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 été 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 "Rafraîchir" 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 été 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'accueil
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
disabled={awaitingRemoteAccept}
|
||||
disabled={awaitingRemoteAccept || loginState === 'S_LOGIN_SUCCESS' || loginState === 'S_LOGIN_FAILURE'}
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
|
||||
216
userwallet/src/components/MemberSelectionScreen.tsx
Normal file
216
userwallet/src/components/MemberSelectionScreen.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
*/
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
# website-skeleton
|
||||
|
||||
Squelette d’un 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 d’installer ou builder le skeleton.
|
||||
- **UserWallet** : à servir sur l’URL configurée (voir ci‑dessous) pour que l’iframe 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 ci‑dessous) pour que l'iframe fonctionne.
|
||||
|
||||
## Installation
|
||||
|
||||
@ -21,26 +21,56 @@ npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Ouvre par défaut sur `http://localhost:3024`. L’iframe 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 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. Remplacez‑le par les validateurs de l’action login de votre contrat (ou chargez‑les 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 l’URL 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 : l’iframe affiche UserWallet.
|
||||
4. **Demander auth** : bouton « Demander auth (auth-request) » → envoi de `auth-request` à l’iframe.
|
||||
5. **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é : … ».
|
||||
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 l’iframe, é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
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
64
website-skeleton/src/contract.ts
Normal file
64
website-skeleton/src/contract.ts
Normal 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,
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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 l’iframe.', 'pending');
|
||||
setStatus(
|
||||
'En attente du login depuis l’iframe. Utilisation des validateurs par défaut jusqu’à réception d’un contrat.',
|
||||
'pending',
|
||||
);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user