// ============================================================================ // import.jsx — Modale Import depuis un CSV. // Flux à 3 étapes : // 1. Choix de la source (LockSelf, Bitwarden, 1Password, Chrome, Firefox, // Autre/CSV générique) // 2. Sélection du fichier // 3. Mapping colonne CSV → champ CléPass (auto-rempli selon la source) // ============================================================================ (function (global) { 'use strict'; const { useState, useRef, useMemo, useEffect } = React; // ─── Parseur CSV minimaliste ────────────────────────────────────────────── function parseCSV(text) { const lines = []; let line = [], field = '', inQuotes = false; const len = text.length; for (let i = 0; i < len; i++) { const ch = text[i]; if (inQuotes) { if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; } } else { field += ch; } } else { if (ch === '"') { inQuotes = true; } else if (ch === ',' || ch === ';' || ch === '\t') { line.push(field); field = ''; } else if (ch === '\n' || ch === '\r') { if (ch === '\r' && text[i + 1] === '\n') i++; line.push(field); field = ''; if (line.some(c => c !== '')) lines.push(line); line = []; } else { field += ch; } } } if (field !== '' || line.length) { line.push(field); if (line.some(c => c !== '')) lines.push(line); } return lines; } // ─── Sources et leurs mappings préconfigurés ────────────────────────────── // Les clés `map` sont des HEADERS exacts (case-sensitive sauf indication). // Les valeurs sont les targets CléPass. const TARGETS = [ { value: '', label: '— Ignorer —' }, { value: 'name', label: 'Nom' }, { value: 'domain', label: 'Domaine / URL' }, { value: 'username', label: 'Identifiant' }, { value: 'password', label: 'Mot de passe' }, { value: 'notes', label: 'Notes (description) — concaténé si plusieurs' }, { value: 'folder', label: 'Dossier (chemin Cat/SubCat)' }, { value: 'totp', label: '2FA / TOTP (secret Base32)' }, ]; const IMPORT_SOURCES = [ { id: 'locksoft', label: 'LockSelf', hint: 'Export CSV (séparateur ;)', map: { 'Name': 'name', 'Url': 'domain', 'Username': 'username', 'Password': 'password', 'Category/SubCategory': 'folder', 'Description': 'notes', 'Opt1': 'notes', 'Opt2': 'notes', 'Opt3': 'notes', 'Totp': 'totp', }, }, { id: 'bitwarden', label: 'Bitwarden', hint: 'Export CSV depuis Bitwarden Web Vault', map: { 'name': 'name', 'login_uri': 'domain', 'login_username': 'username', 'login_password': 'password', 'folder': 'folder', 'notes': 'notes', 'login_totp': 'totp', }, }, { id: 'onepassword', label: '1Password', hint: 'Export CSV depuis 1Password 8', map: { 'Title': 'name', 'Url': 'domain', 'Username': 'username', 'Password': 'password', 'Notes': 'notes', 'OTPAuth': 'totp', 'OTP Auth': 'totp', }, }, { id: 'chrome', label: 'Chrome / Edge', hint: 'chrome://password-manager/passwords → Exporter', map: { 'name': 'name', 'url': 'domain', 'username': 'username', 'password': 'password', 'note': 'notes', }, }, { id: 'firefox', label: 'Firefox', hint: 'about:logins → Exporter en CSV', map: { 'url': 'domain', 'username': 'username', 'password': 'password', 'httpRealm': 'notes', }, }, { id: 'generic', label: 'Autre / Auto-détection', hint: 'CSV avec en-têtes lisibles (français ou anglais)', map: null, // auto-detect }, ]; // Aliases pour la détection auto (utilisés en mode 'generic' et en // complément quand la source n'a pas de match exact) const HEADER_ALIASES = { name: ['name', 'title', 'nom', 'libelle', 'libellé', 'service', 'site'], domain: ['url', 'uri', 'domain', 'domaine', 'website', 'web', 'adresse', 'login_uri'], username: ['username', 'user', 'email', 'login', 'identifiant', 'utilisateur', 'courriel', 'login_username'], password: ['password', 'motdepasse', 'mot de passe', 'pass', 'pwd', 'secret', 'login_password'], notes: ['note', 'notes', 'comment', 'commentaire', 'remarque', 'description', 'extra', 'opt1', 'opt2', 'opt3', 'option1', 'option2', 'option3'], folder: ['folder', 'dossier', 'category', 'categorie', 'catégorie', 'category/subcategory', 'subcategory', 'sous-categorie', 'group', 'groupe', 'collection'], totp: ['totp', '2fa', 'otp', 'otpauth', 'login_totp', 'otp_secret', 'authenticator', 'otp auth'], }; // Construit le mapping {colIdx: targetField} pour une liste d'en-têtes, // selon la source choisie. Le map de la source prime, fallback sur aliases. function autoMapForSource(headers, sourceId) { const source = IMPORT_SOURCES.find(s => s.id === sourceId); const sourceMap = source?.map || null; const out = {}; headers.forEach((h, i) => { const trimmed = String(h || '').trim(); if (!trimmed) return; // 1) Match exact via le preset de la source. if (sourceMap && sourceMap[trimmed] !== undefined) { out[i] = sourceMap[trimmed]; return; } // 2) Fallback : aliases génériques (case-insensitive). const lc = trimmed.toLowerCase(); for (const [target, aliases] of Object.entries(HEADER_ALIASES)) { if (aliases.includes(lc)) { out[i] = target; return; } } // 3) Sinon : ignoré (pas dans le mapping). }); return out; } // ─── Création de sous-dossiers à partir des paths « Cat/SubCat » ───────── function ensureFoldersFromPaths(paths, currentFolders) { const cloned = JSON.parse(JSON.stringify(currentFolders || [])); const pathToId = new Map(); const findChild = (list, name) => list.find(f => (f.name || '').trim().toLowerCase() === name.toLowerCase()); paths.forEach(path => { const segments = String(path).split('/').map(s => s.trim()).filter(Boolean); if (segments.length === 0) return; let parent = null; let list = cloned; segments.forEach((seg, idx) => { let f = findChild(list, seg); if (!f) { f = { id: CPCrypto.genId('f'), name: seg, icon: idx === 0 ? 'tool' : 'note', parentId: parent ? parent.id : null, children: [], }; list.push(f); } if (!f.children) f.children = []; parent = f; list = f.children; }); if (parent) pathToId.set(path, parent.id); }); return { folders: cloned, pathToId }; } // ─── Modal ──────────────────────────────────────────────────────────────── const ImportModal = ({ folders, onImport, onCancel }) => { // step: 'source' | 'file' | 'mapping' const [step, setStep] = useState('source'); const [source, setSource] = useState(null); // id de la source choisie const [rawText, setRawText] = useState(''); const [rows, setRows] = useState([]); const [headers, setHeaders] = useState([]); const [reverseMap, setReverseMap] = useState({}); // { colIdx: target } const [fileName, setFileName] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [progress, setProgress] = useState({ done: 0, total: 0 }); const [defaultFolder, setDefaultFolder] = useState(''); const [hasHeader, setHasHeader] = useState(true); const fileInputRef = useRef(null); // ─── Étape Fichier ────────────────────────────────────────────────────── const handleFile = async (file) => { if (!file) return; setError(''); setFileName(file.name); try { const text = await file.text(); setRawText(text); parseAndDetect(text, hasHeader); setStep('mapping'); } catch (err) { setError('Lecture du fichier impossible : ' + err.message); } }; const parseAndDetect = (text, withHeader) => { const all = parseCSV(text); if (all.length === 0) { setError('Fichier vide ou non valide.'); return; } let h, body; if (withHeader) { h = all[0]; body = all.slice(1); } else { h = all[0].map((_, i) => 'Colonne ' + (i + 1)); body = all; } setHeaders(h); setRows(body); setReverseMap(autoMapForSource(h, source)); }; const onDrop = (e) => { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (file) handleFile(file); }; const updateRev = (colIdx, target) => { setReverseMap(m => ({ ...m, [colIdx]: target })); }; // ─── Construction du payload final ────────────────────────────────────── const buildPayload = () => { // Inversion : { target: [colIdx, colIdx, ...] } const targetToCols = {}; Object.entries(reverseMap).forEach(([colIdxStr, target]) => { if (!target) return; const idx = Number(colIdxStr); if (!targetToCols[target]) targetToCols[target] = []; targetToCols[target].push(idx); }); // Helper : récupère la première valeur non vide pour un target const firstValue = (row, target) => { const cols = targetToCols[target]; if (!cols) return ''; for (const i of cols) { const v = (row[i] || '').trim(); if (v) return v; } return ''; }; // Helper : concatène toutes les valeurs notes (avec préfixe header sauf // pour la première qui prend la valeur brute si c'est la "vraie" // description). const buildNotes = (row) => { const cols = targetToCols.notes || []; const parts = []; cols.forEach((i, idx) => { const v = (row[i] || '').trim(); if (!v) return; // Si plusieurs colonnes mappées notes, on préfixe avec le header // pour distinguer (sauf la 1re qui passe en clair). if (cols.length === 1 || idx === 0) parts.push(v); else parts.push(`${headers[i]} : ${v}`); }); return parts.join('\n'); }; // 1. Pré-création des sous-dossiers à partir des paths. const paths = new Set(); (targetToCols.folder || []).forEach(i => { rows.forEach(row => { const v = (row[i] || '').trim(); if (v) paths.add(v); }); }); const { folders: newFolders, pathToId } = ensureFoldersFromPaths(Array.from(paths), folders); // 2. Construit les entries. const entries = []; rows.forEach((row, i) => { const name = firstValue(row, 'name') || firstValue(row, 'domain') || ('Importé ' + (i + 1)); const username = firstValue(row, 'username'); const password = firstValue(row, 'password'); const totpSecret = firstValue(row, 'totp'); const notes = buildNotes(row); // Skip si tout est vide. if (!password && !username && !notes && !totpSecret) return; const domain = firstValue(row, 'domain').replace(/^https?:\/\//, '').replace(/\/.*$/, ''); const csvPath = firstValue(row, 'folder'); const folderId = (csvPath && pathToId.get(csvPath)) || defaultFolder || null; const accountId = CPCrypto.genId('a'); const accounts = [{ id: accountId, label: 'Principal', username, password, created: new Date().toLocaleDateString('fr-FR'), }]; const totp = []; let extraNotes = ''; if (totpSecret) { try { CPCrypto.base32Decode(totpSecret); totp.push({ id: CPCrypto.genId('t'), label: 'Authenticator', secret: totpSecret.replace(/\s+/g, '').toUpperCase(), period: 30, code: '------', remaining: 30, accountId, }); } catch { extraNotes = `\nTOTP non valide (${totpSecret})`; } } entries.push({ id: CPCrypto.genId('e'), name, domain, category: 'imported', folderId, favorite: false, shared: false, modified: 'importé', color: CPCrypto.colorFor(name), avatarType: 'letter', iconName: null, strength: CPCrypto.passwordStrength(password).level, accounts, totp, notes: notes + extraNotes, }); }); return { entries, folders: newFolders }; }; const submit = async () => { setError(''); const payload = buildPayload(); if (payload.entries.length === 0) { setError('Aucune entrée à importer (vérifiez le mapping des colonnes).'); return; } setBusy(true); setProgress({ done: 0, total: payload.entries.length }); try { await onImport(payload); setProgress({ done: payload.entries.length, total: payload.entries.length }); } catch (err) { setError(err.message || 'Échec de l’import'); setBusy(false); } }; const goBack = () => { if (step === 'mapping') { setStep('file'); setRows([]); setHeaders([]); setReverseMap({}); setFileName(''); setRawText(''); } else if (step === 'file') { setStep('source'); } }; const folderOptions = useMemo(() => { const out = [{ value: '', label: '— Selon le CSV ou aucun —' }]; folders.forEach(f => { out.push({ value: f.id, label: f.name }); (f.children || []).forEach(c => out.push({ value: c.id, label: ' ' + f.name + ' › ' + c.name })); }); return out; }, [folders]); // Validation : il faut au moins une colonne mappée vers 'name'. const valid = rows.length > 0 && Object.values(reverseMap).some(t => t === 'name'); // Compte des entrées qui passeraient le filtre. const importableCount = useMemo(() => { if (rows.length === 0) return 0; return buildPayload().entries.length; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows, reverseMap, defaultFolder]); // ─── Rendu ────────────────────────────────────────────────────────────── const renderSourceStep = () => ( <>
Choisissez le service d'origine. CléPass adaptera automatiquement les correspondances de colonnes.
{step === 'source' && 'Étape 1 / 3 — Choisir la source des données.'} {step === 'file' && 'Étape 2 / 3 — Déposer le fichier CSV.'} {step === 'mapping' && 'Étape 3 / 3 — Vérifier la correspondance des colonnes.'}