// ============================================================================
// modals.jsx — Add / Edit pour entrées (mot de passe + Wi-Fi).
// Toutes les modifs passent par la callback onSave qui re-chiffre le coffre
// côté app.jsx puis pousse via PUT /api.php?action=vault.
// ============================================================================
(function (global) {
'use strict';
const { useState, useMemo } = React;
// ─── Modal de base : header + corps + footer + Esc/Backdrop ──────────────
function ModalShell({ title, subtitle, children, onCancel, footer }) {
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onCancel(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onCancel]);
return (
e.stopPropagation()}>
{title}
{subtitle &&
{subtitle}
}
{children}
{footer}
);
}
// ─── Champ texte étiqueté ────────────────────────────────────────────────
function TextField({ label, value, onChange, type = 'text', placeholder, autoFocus, required, mono }) {
return (
);
}
function Textarea({ label, value, onChange, placeholder, rows = 3 }) {
return (
);
}
function SelectField({ label, value, onChange, options }) {
return (
);
}
function Toggle({ label, value, onChange }) {
return (
{label}
);
}
// ─── Champ password avec toggle visibilité + générateur + force ───────────
function PasswordField({ label, value, onChange, autoFocus }) {
const [show, setShow] = useState(false);
const [genOpen, setGenOpen] = useState(false);
return (
);
}
function PwdStrengthBar({ value }) {
const s = CPCrypto.passwordStrength(value);
const colors = { weak: '#EF4444', medium: '#F59E0B', strong: '#10B981' };
const labels = { weak: 'Faible', medium: 'Moyen', strong: 'Fort' };
if (!value) return null;
return (
);
}
function PwdGenerator({ onApply, onCancel }) {
const [length, setLength] = useState(20);
const [upper, setUpper] = useState(true);
const [lower, setLower] = useState(true);
const [digits, setDigits] = useState(true);
const [symbols, setSymbols] = useState(true);
const [noAmbig, setNoAmbig] = useState(false);
const [pwd, setPwd] = useState(() => CPCrypto.generatePassword({ length: 20 }));
const regen = () => {
try {
setPwd(CPCrypto.generatePassword({ length, upper, lower, digits, symbols, noAmbiguous: noAmbig }));
} catch (e) { setPwd(''); }
};
React.useEffect(regen, [length, upper, lower, digits, symbols, noAmbig]);
return (
{pwd || '⚠ Choisissez au moins une catégorie'}
);
}
// ─── Helpers d'options ───────────────────────────────────────────────────
function folderOptions(folders) {
const opts = [{ value: '', label: '— Aucun —' }];
folders.forEach(f => {
opts.push({ value: f.id, label: f.name });
(f.children || []).forEach(c => opts.push({ value: c.id, label: ' ' + f.name + ' › ' + c.name }));
});
return opts;
}
// ─── EntryModal (mot de passe) ────────────────────────────────────────────
const EntryModal = ({ mode, target, folders, onSave, onCancel }) => {
const isAdd = mode === 'add';
const [name, setName] = useState(target?.name || '');
const [domain, setDomain] = useState(target?.domain || '');
const [folderId, setFolderId] = useState(target?.folderId || '');
const [favorite, setFavorite] = useState(!!target?.favorite);
const [shared, setShared] = useState(!!target?.shared);
const [accounts, setAccounts] = useState(
target?.accounts?.length
? target.accounts.map(a => ({ ...a }))
: [{ id: CPCrypto.genId('a'), label: 'Principal', username: '', password: '', created: new Date().toLocaleDateString('fr-FR') }]
);
const [totps, setTotps] = useState(target?.totp ? target.totp.map(t => ({ ...t })) : []);
const [notes, setNotes] = useState(target?.notes || '');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const updateAccount = (i, patch) => {
setAccounts(arr => arr.map((a, k) => k === i ? { ...a, ...patch } : a));
};
const addAccount = () => setAccounts(arr => [...arr, {
id: CPCrypto.genId('a'), label: 'Compte ' + (arr.length + 1),
username: '', password: '', created: new Date().toLocaleDateString('fr-FR'),
}]);
const removeAccount = (i) => setAccounts(arr => arr.length > 1 ? arr.filter((_, k) => k !== i) : arr);
const updateTotp = (i, patch) =>
setTotps(arr => arr.map((t, k) => k === i ? { ...t, ...patch } : t));
const addTotp = () => setTotps(arr => [...arr, {
id: CPCrypto.genId('t'), label: 'Authenticator', secret: '', period: 30,
}]);
const removeTotp = (i) => setTotps(arr => arr.filter((_, k) => k !== i));
const submit = async (e) => {
e.preventDefault();
setError('');
if (!name.trim()) return setError('Nom requis.');
if (accounts.some(a => !a.username || !a.password))
return setError('Chaque compte doit avoir un identifiant et un mot de passe.');
// Validation Base32 des secrets TOTP
for (const t of totps) {
if (t.secret) {
try { CPCrypto.base32Decode(t.secret); }
catch { return setError(`Secret TOTP invalide pour "${t.label}" (Base32 attendu).`); }
}
}
// Strength = niveau du compte le plus faible.
const levels = accounts.map(a => CPCrypto.passwordStrength(a.password).level);
const order = { weak: 0, medium: 1, strong: 2 };
const worst = levels.reduce((acc, l) => order[l] < order[acc] ? l : acc, 'strong');
const entry = {
id: target?.id || CPCrypto.genId('e'),
name: name.trim(),
domain: domain.trim(),
category: target?.category || 'other',
folderId: folderId || null,
favorite, shared,
modified: 'à l\'instant',
color: target?.color || CPCrypto.colorFor(name.trim()),
strength: worst,
accounts: accounts.map(a => ({
id: a.id, label: a.label || 'Principal',
username: a.username, password: a.password,
created: a.created || new Date().toLocaleDateString('fr-FR'),
})),
totp: totps.map(t => ({
id: t.id, label: t.label || 'Authenticator',
secret: t.secret, period: +t.period || 30, code: '------', remaining: 30,
})),
notes,
};
setBusy(true);
try { await onSave(entry); }
catch (err) { setError(err.message || 'Échec de la sauvegarde'); setBusy(false); }
};
return (
>
}
>
);
};
// ─── WifiModal ────────────────────────────────────────────────────────────
const WifiModal = ({ mode, target, folders, onSave, onCancel }) => {
const isAdd = mode === 'add';
const [name, setName] = useState(target?.name || '');
const [ssid, setSsid] = useState(target?.ssid || '');
const [password, setPassword] = useState(target?.password || '');
const [security, setSecurity] = useState(target?.security || 'WPA2');
const [hidden, setHidden] = useState(!!target?.hidden);
const [folderId, setFolderId] = useState(target?.folderId || '');
const [favorite, setFavorite] = useState(!!target?.favorite);
const [shared, setShared] = useState(!!target?.shared);
const [notes, setNotes] = useState(target?.notes || '');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setError('');
if (!name.trim()) return setError('Nom requis.');
if (!ssid.trim()) return setError('SSID requis.');
const strength = CPCrypto.passwordStrength(password).level;
const entry = {
id: target?.id || CPCrypto.genId('w'),
type: 'wifi',
name: name.trim(),
ssid: ssid.trim(),
domain: ssid.trim(),
security, hidden,
password,
folderId: folderId || null,
favorite, shared,
modified: 'à l\'instant',
color: target?.color || CPCrypto.colorFor(ssid.trim() || name.trim()),
strength,
accounts: [{ id: (target?.id || 'w') + '-a', label: 'SSID', username: ssid.trim(), password, created: '' }],
totp: [],
notes,
};
setBusy(true);
try { await onSave(entry); }
catch (err) { setError(err.message || 'Échec de la sauvegarde'); setBusy(false); }
};
return (
>
}
>
);
};
global.EntryModal = EntryModal;
global.WifiModal = WifiModal;
})(window);