// ============================================================================
// 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 (
);
}
// ============================================================================
// 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();