// ============================================================================ // app.jsx — Racine de l'application CléPass. // Gère : // - le démarrage (vérifie la session, choisit auth/unlock/login), // - l'écran d'authentification, // - le coffre déchiffré localement et sa sauvegarde côté serveur, // - le verrouillage manuel et automatique, // - les modals d'ajout/édition (chargées depuis modals.jsx). // ============================================================================ const { useState, useEffect, useMemo, useRef, useCallback } = React; // Filtres latéraux : constants (pas dans le coffre, c'est l'UI). const FILTERS = [ { id: 'all', label: 'Tous', icon: 'all', accent: '#6366F1', count: 0 }, { id: 'passwords', label: 'Mots de passe', icon: 'key', accent: '#06B6D4', count: 0 }, { id: 'totp', label: '2FA', icon: 'shield', accent: '#F59E0B', count: 0 }, { id: 'wifi', label: 'Wi-Fi', icon: 'wifi', accent: '#0EA5E9', count: 0 }, { id: 'passkeys', label: 'Passkeys', icon: 'fingerprint', accent: '#10B981', count: 0 }, { id: 'favorites', label: 'Favoris', icon: 'star', accent: '#EC4899', count: 0 }, { id: 'security', label: 'Sécurité', icon: 'alert', accent: '#EF4444', count: 0 }, { id: 'shared', label: 'Partagés', icon: 'share', accent: '#8B5CF6', count: 0 }, ]; // Calcul des compteurs dynamiques pour filtres et dossiers. function withCounts(filters, folders, entries) { const f = filters.map(x => ({ ...x })); f[0].count = entries.filter(e => e.folderId !== 'trash').length; f[1].count = entries.filter(e => e.type !== 'wifi').reduce((s, e) => s + (e.accounts?.length || 0), 0); f[2].count = entries.reduce((s, e) => s + (e.totp ? e.totp.length : 0), 0); f[3].count = entries.filter(e => e.type === 'wifi').length; f[4].count = 0; f[5].count = entries.filter(e => e.favorite).length; f[6].count = entries.filter(e => e.strength && e.strength !== 'strong').length; f[7].count = entries.filter(e => e.shared).length; const countFolder = (id) => entries.filter(e => e.folderId === id).length; const fld = (folders || []).map(x => ({ ...x, count: countFolder(x.id) + (x.children || []).reduce((s, c) => s + countFolder(c.id), 0), children: (x.children || []).map(c => ({ ...c, count: countFolder(c.id) })), })); return [f, fld]; } // Normalisation d'une entrée Wi-Fi (cohérent avec data.jsx historique). function normalizeEntry(e) { if (e.type === 'wifi') { e.domain = e.ssid; if (!e.accounts || e.accounts.length === 0) { e.accounts = [{ id: e.id + '-a', label: 'SSID', username: e.ssid, password: e.password, created: '' }]; } if (!e.totp) e.totp = []; } if (!e.accounts) e.accounts = []; if (!e.totp) e.totp = []; return e; } // Tweaks par défaut (persistés via le canal du design tool ; sans hôte ils // ne servent qu'à initialiser le state local). const TWEAK_DEFAULTS = { theme: 'dark', accent: 'indigo-rose', density: 'regular', iconStyle: 'monogram', }; const ACCENTS = { 'indigo-rose': { from: '#6366F1', to: '#EC4899', solid: '#6366F1' }, 'emerald': { from: '#10B981', to: '#059669', solid: '#10B981' }, 'amber': { from: '#F59E0B', to: '#EA580C', solid: '#F59E0B' }, 'violet': { from: '#8B5CF6', to: '#6366F1', solid: '#8B5CF6' }, 'crimson': { from: '#EF4444', to: '#DC2626', solid: '#EF4444' }, }; // ─── Délai d'inactivité (verrouillage auto) ───────────────────────────────── const IDLE_LOCK_MS = 15 * 60 * 1000; // 15 min sans activité → lock // ============================================================================ // Splash — affiché pendant la vérification de session au démarrage // ============================================================================ function Splash() { return (
CléPass
); } // ============================================================================ // Vault — l'application principale (sidebar + list + detail) // ============================================================================ function Vault({ vault, vaultVersion, encKey, sessionInfo, onMutate, onLock, onLogout, onChangeMaster, }) { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [activeFilter, setActiveFilter] = useState('all'); const [activeFolder, setActiveFolder] = useState(null); const [selectedId, setSelectedId] = useState(vault.entries[0]?.id || null); const [query, setQuery] = useState(''); const [expanded, setExpanded] = useState({ perso: true, travail: true }); const [encOpen, setEncOpen] = useState(false); const [modal, setModal] = useState(null); // 'add-password' | 'add-wifi' | 'edit' | null const [editTarget, setEditTarget] = useState(null); const [saveState, setSaveState] = useState({ status: 'idle', message: '' }); // idle|saving|saved|error // Application thème + accent au body. useEffect(() => { document.body.classList.toggle('dark', t.theme === 'dark'); document.body.classList.toggle('light', t.theme === 'light'); const a = ACCENTS[t.accent] || ACCENTS['indigo-rose']; document.body.style.setProperty('--accent-from', a.from); document.body.style.setProperty('--accent-to', a.to); document.body.style.setProperty('--accent', a.solid); }, [t.theme, t.accent]); // Calcul des filtres/dossiers avec compteurs. const [filters, folders] = useMemo( () => withCounts(FILTERS, vault.folders, vault.entries), [vault.folders, vault.entries] ); // Filtrage de la liste. const filtered = useMemo(() => { let list = vault.entries.map(normalizeEntry); if (activeFolder) { list = list.filter(e => e.folderId === activeFolder || (folders.find(f => f.id === activeFolder)?.children || []).some(c => c.id === e.folderId)); } else { switch (activeFilter) { case 'passwords': list = list.filter(e => e.type !== 'wifi'); break; case 'totp': list = list.filter(e => e.totp && e.totp.length > 0); break; case 'wifi': list = list.filter(e => e.type === 'wifi'); break; case 'favorites': list = list.filter(e => e.favorite); break; case 'security': list = list.filter(e => e.strength && e.strength !== 'strong'); break; case 'shared': list = list.filter(e => e.shared); break; case 'passkeys': list = []; break; } } if (query) { const q = query.toLowerCase(); list = list.filter(e => (e.name || '').toLowerCase().includes(q) || (e.domain || '').toLowerCase().includes(q) || (e.accounts || []).some(a => (a.username || '').toLowerCase().includes(q)) ); } return list.slice().sort((a, b) => (a.name || '').localeCompare(b.name || '')); }, [activeFilter, activeFolder, query, vault.entries, folders]); // Auto-sélection si l'entrée courante a disparu. useEffect(() => { if (filtered.length > 0 && !filtered.some(e => e.id === selectedId)) { setSelectedId(filtered[0].id); } else if (filtered.length === 0) { setSelectedId(null); } }, [filtered, selectedId]); const selected = vault.entries.find(e => e.id === selectedId) || null; const titleForView = activeFolder ? (folders.find(f => f.id === activeFolder)?.name || folders.flatMap(f => f.children || []).find(c => c.id === activeFolder)?.name || 'Dossier') : (filters.find(f => f.id === activeFilter)?.label || 'Tous'); // ─── Verrouillage automatique sur inactivité ───────────────────────────── useEffect(() => { let timer = null; const reset = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => onLock(), IDLE_LOCK_MS); }; const events = ['mousemove', 'keydown', 'click', 'touchstart', 'visibilitychange']; events.forEach(ev => window.addEventListener(ev, reset, { passive: true })); reset(); return () => { events.forEach(ev => window.removeEventListener(ev, reset)); if (timer) clearTimeout(timer); }; }, [onLock]); // ─── Indicateur de sauvegarde, helper de mutation ───────────────────────── const mutate = useCallback(async (newVault) => { setSaveState({ status: 'saving', message: 'Chiffrement et sauvegarde…' }); try { await onMutate(newVault); setSaveState({ status: 'saved', message: 'Sauvegardé' }); setTimeout(() => setSaveState(s => s.status === 'saved' ? { status: 'idle', message: '' } : s), 1800); } catch (e) { setSaveState({ status: 'error', message: e.message || 'Erreur de sauvegarde' }); throw e; } }, [onMutate]); // ─── CRUD entrées ───────────────────────────────────────────────────────── const addEntry = async (entry) => { const next = { ...vault, entries: [...(vault.entries || []), entry] }; await mutate(next); setSelectedId(entry.id); }; const updateEntry = (entry) => { const next = { ...vault, entries: (vault.entries || []).map(e => e.id === entry.id ? entry : e), }; return mutate(next); }; const deleteEntry = (entryId) => { const next = { ...vault, entries: (vault.entries || []).filter(e => e.id !== entryId), }; return mutate(next); }; const toggleFavorite = (entryId) => { const next = { ...vault, entries: (vault.entries || []).map(e => e.id === entryId ? { ...e, favorite: !e.favorite } : e), }; return mutate(next); }; // ─── Importer les données de démo (depuis window.CLEPASS_DEMO_DATA) ────── const importDemo = async () => { if (!window.CLEPASS_DEMO_DATA) return; const { ENTRIES, FOLDERS } = window.CLEPASS_DEMO_DATA; const next = { ...vault, entries: ENTRIES.map(e => ({ ...e })), folders: FOLDERS.map(f => ({ ...f })), }; await mutate(next); setSelectedId(next.entries[0]?.id || null); }; const isEmpty = vault.entries.length === 0; const [sidebarOpen, setSidebarOpen] = useState(false); return (
CléPass
{sessionInfo.email}
{sidebarOpen && (
setSidebarOpen(false)} /> )} { setActiveFilter(id); setActiveFolder(null); setSidebarOpen(false); }} onFolder={(id) => { setActiveFolder(id); setSidebarOpen(false); }} expanded={expanded} onToggleFolder={(id) => setExpanded(x => ({ ...x, [id]: !x[id] }))} dark={t.theme === 'dark'} isOpen={sidebarOpen} /> setModal('add-password')} onAddWifi={() => setModal('add-wifi')} /> {isEmpty ? ( setModal('add-password')} onAddWifi={() => setModal('add-wifi')} onImportDemo={importDemo} /> ) : selected && selected.type === 'wifi' ? ( setEncOpen(true)} onEdit={() => { setEditTarget(selected); setModal('edit-wifi'); }} onDelete={() => deleteEntry(selected.id)} onToggleFavorite={() => toggleFavorite(selected.id)} onBack={() => setSelectedId(null)} /> ) : selected ? ( setEncOpen(true)} onEdit={() => { setEditTarget(selected); setModal('edit-password'); }} onDelete={() => deleteEntry(selected.id)} onToggleFavorite={() => toggleFavorite(selected.id)} onUpdate={updateEntry} onBack={() => setSelectedId(null)} /> ) : ( setEncOpen(true)} /> )}
setEncOpen(false)} /> {/* Modals CRUD */} {(modal === 'add-password' || modal === 'edit-password') && ( { if (modal === 'add-password') return addEntry(entry).then(() => setModal(null)); return updateEntry(entry).then(() => setModal(null)); }} onCancel={() => { setModal(null); setEditTarget(null); }} /> )} {(modal === 'add-wifi' || modal === 'edit-wifi') && ( { if (modal === 'add-wifi') return addEntry(entry).then(() => setModal(null)); return updateEntry(entry).then(() => setModal(null)); }} onCancel={() => { setModal(null); setEditTarget(null); }} /> )} setTweak('theme', v)} /> setTweak('accent', v)} /> setEncOpen(true)} />
); } // ============================================================================ // Indicateur de sauvegarde dans la titlebar // ============================================================================ function SaveIndicator({ state }) { if (state.status === 'idle') return null; return ( {state.status === 'saving' && } {state.status === 'saved' && } {state.status === 'error' && } {state.message} ); } // ============================================================================ // EmptyVault — état vide accueillant + import démo // ============================================================================ function EmptyVault({ onAddPassword, onAddWifi, onImportDemo }) { return (

Votre coffre est vide

Ajoutez un mot de passe ou un Wi-Fi pour commencer. Tout est chiffré dans ce navigateur avant d'être envoyé au serveur.

{window.CLEPASS_DEMO_DATA && (
)}
); } // ============================================================================ // App — composant racine, gère le cycle de vie // ============================================================================ function App() { const [phase, setPhase] = useState('checking'); // checking|auth|app const [authMode, setAuthMode] = useState('login'); const [sessionInfo, setSessionInfo] = useState(null); // Données déchiffrées (mémoire seulement). const [encKey, setEncKey] = useState(null); const [vault, setVault] = useState(null); const [vaultVersion, setVaultVersion] = useState(0); // Vérification de session au démarrage. useEffect(() => { (async () => { try { const me = await CPApi.me(); setSessionInfo({ email: me.email, kdfSalt: me.kdf_salt, kdfParams: me.kdf_params }); setAuthMode('unlock'); setPhase('auth'); } catch (e) { setAuthMode('login'); setPhase('auth'); } })(); }, []); function handleAuthenticated(result) { setSessionInfo({ email: result.email, kdfSalt: result.kdfSalt, kdfParams: result.kdfParams }); setEncKey(result.encKey); setVault(result.vault); setVaultVersion(result.vaultVersion); setPhase('app'); } function handleLock() { if (encKey) CPCrypto.wipe(encKey); setEncKey(null); setVault(null); setVaultVersion(0); setAuthMode('unlock'); setPhase('auth'); } async function handleLogout() { try { await CPApi.logout(); } catch {} if (encKey) CPCrypto.wipe(encKey); setEncKey(null); setVault(null); setVaultVersion(0); setSessionInfo(null); setAuthMode('login'); setPhase('auth'); } function handleChangeMaster() { // v1 : on annonce et on déconnecte. Le change-password complet sera dans // une modal dédiée. Le bouton est présent pour signaler la fonction côté // serveur (handler /change-password déjà implémenté). alert( "Changement de mot de passe maître : pour v1, déconnectez-vous puis " + "utilisez un client API ou attendez la modal dédiée." ); } // Pousser une nouvelle version du coffre (chiffrement local + PUT /vault). const pushVault = useCallback(async (newVault) => { if (!encKey) throw new Error('Coffre verrouillé'); const { blob, iv } = await CPCrypto.encryptJSON(encKey, newVault); const r = await CPApi.vaultPut({ vault_blob: blob, vault_iv: iv, expected_version: vaultVersion, }); setVault(newVault); setVaultVersion(r.vault_version); }, [encKey, vaultVersion]); if (phase === 'checking') return ; if (phase === 'auth') { return ; } return ( ); } ReactDOM.createRoot(document.getElementById('root')).render();