import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Plus, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert, MoreVertical, Mail, Globe, Server, Database, Cloud, Shield, Lock, Wifi, CreditCard, Building2, Printer, Smartphone, KeyRound, HardDrive, Router, UserCog, } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAction } from '../../hooks/useAsync.js'; import { useDebouncedValue } from '../../hooks/useDebouncedValue.js'; import { getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin, setVaultToken, listVaultEntries, getVaultEntry, createVaultEntry, updateVaultEntry, deleteVaultEntry, } from '../../services/vault.js'; import { Button } from '../../components/Button.jsx'; import { Field } from '../../components/Field.jsx'; import { PasswordInput } from '../../components/PasswordInput.jsx'; import { DetailPopup } from '../../components/DetailPopup.jsx'; import { Flyout, FlyoutItem } from '../../components/Flyout.jsx'; import { SearchInput } from '../../components/SearchInput.jsx'; import { BRAND_ICONS } from '../../data/brandIcons.js'; const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '', iconKey: '' }; const AUTO_LOCK_MS = 3 * 60 * 1000; // 3 minutos: salir solo del menu si queda abierto sin atencion // Catalogo fijo de iconos genericos (debe coincidir con backend/src/modules/vault/service.js#ICON_KEYS) -- // a proposito NO se usa ningun servicio externo de favicon (ej. google/s2/favicons): revelaria a // terceros que urls internas tiene guardadas cada usuario, inaceptable para un repositorio de // credenciales. El usuario elige uno de esta lista, o uno de marca (BRAND_ICONS), o se deja // "Auto" (inicial del nombre). const ICONS = { mail: Mail, globe: Globe, server: Server, database: Database, cloud: Cloud, shield: Shield, lock: Lock, wifi: Wifi, card: CreditCard, building: Building2, printer: Printer, phone: Smartphone, key: KeyRound, disk: HardDrive, router: Router, user: UserCog, }; function BrandSvg({ icon, size = 22 }) { return ( ); } function EntryIcon({ iconKey, title, size = 22 }) { const Icon = ICONS[iconKey]; const brand = BRAND_ICONS[iconKey]; return (
{Icon ? : brand ? : {(title || '?').trim().charAt(0).toUpperCase()}}
); } function randomPassword(length = 20) { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'; const bytes = crypto.getRandomValues(new Uint32Array(length)); return Array.from(bytes, (b) => chars[b % chars.length]).join(''); } export default function ContrasenasPage() { const { t, pushToast, openDialog } = useUi(); const runAction = useAction(); const navigate = useNavigate(); const [locked, setLocked] = useState(true); const [checking, setChecking] = useState(true); const [hasPinSet, setHasPinSet] = useState(null); const [entries, setEntries] = useState(null); const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search, 300); const [pin, setPin] = useState(''); const [pinConfirm, setPinConfirm] = useState(''); const [pinError, setPinError] = useState(''); const [busy, setBusy] = useState(false); const [selected, setSelected] = useState(null); // entrada abierta en el popup const [editing, setEditing] = useState(false); const [form, setForm] = useState(EMPTY_FORM); const [revealedPassword, setRevealedPassword] = useState(null); const [passwordVisible, setPasswordVisible] = useState(false); const [creating, setCreating] = useState(false); const [changingPin, setChangingPin] = useState(false); const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' }); const [pinChangeError, setPinChangeError] = useState(''); async function loadEntries(query) { const data = await listVaultEntries(query); setEntries(data); setLocked(false); } async function checkAccess() { setChecking(true); try { await loadEntries(); } catch { setLocked(true); try { const status = await getVaultStatus(); setHasPinSet(status.hasPinSet); } catch { /* ignora, se reintenta al enviar el pin */ } } finally { setChecking(false); } } useEffect(() => { checkAccess(); }, []); // Mientras esta desbloqueado, cada cambio de busqueda (con debounce) vuelve a pedir el listado // ya filtrado -- nombre/usuario/enlace se comparan tal cual, las notas se descifran solo en el // servidor para comparar (nunca viaja el contenido al cliente, ver vault/service.js#listEntries). useEffect(() => { if (locked || checking) return; loadEntries(debouncedSearch).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearch]); // Por seguridad: salir de este menu (a otro, o cerrando la pestaña) siempre bloquea de nuevo -- // el token de desbloqueo se borra al desmontar, asi que la proxima vez que se entra aca pide el // PIN de nuevo, sin importar cuanto haya durado la sesion del sistema. useEffect(() => () => setVaultToken(null), []); // Si queda abierto sin atencion, sale solo al Panel a los 3 minutos de haberse desbloqueado. useEffect(() => { if (locked) return; const timer = setTimeout(() => { pushToast({ tone: 'warning', message: t('vault_auto_lock_notice') }); navigate('/'); }, AUTO_LOCK_MS); return () => clearTimeout(timer); }, [locked]); async function submitPin() { setPinError(''); if (!/^\d{4}$/.test(pin)) { setPinError(t('vault_pin_invalid')); return; } setBusy(true); try { if (!hasPinSet) { if (pin !== pinConfirm) { setPinError(t('vault_pin_mismatch')); setBusy(false); return; } await createVaultPin(pin); } await unlockVault(pin); setPin(''); setPinConfirm(''); await loadEntries(); } catch (err) { setPinError(err.message); } finally { setBusy(false); } } async function handleForgotPin() { await runAction(async () => { await forgotVaultPin(); pushToast({ tone: 'success', message: t('vault_forgot_pin_sent') }); }); } async function submitChangePin() { setPinChangeError(''); if (!/^\d{4}$/.test(pinChangeForm.next)) { setPinChangeError(t('vault_pin_invalid')); return; } if (pinChangeForm.next !== pinChangeForm.next2) { setPinChangeError(t('vault_pin_mismatch')); return; } try { await changeVaultPin(pinChangeForm.current, pinChangeForm.next); setChangingPin(false); setPinChangeForm({ current: '', next: '', next2: '' }); pushToast({ tone: 'success', message: t('common_saved') }); } catch (err) { setPinChangeError(err.message); } } function openEntry(entry) { setSelected(entry); setForm({ title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '', iconKey: entry.icon_key || '' }); setRevealedPassword(null); setPasswordVisible(false); setEditing(false); setCreating(false); } function openCreate() { setSelected(null); setForm(EMPTY_FORM); setRevealedPassword(null); setPasswordVisible(false); setEditing(true); setCreating(true); } function closePopup() { setSelected(null); setCreating(false); setEditing(false); } async function reveal() { await runAction(async () => { const full = await getVaultEntry(selected.id); setRevealedPassword(full.password); setForm((f) => ({ ...f, notes: full.notes || '' })); setPasswordVisible(true); }); } function startEdit() { setForm((f) => ({ ...f, password: revealedPassword || '' })); setEditing(true); } function cancelEdit() { if (creating) { closePopup(); return; } setEditing(false); openEntry(selected); } async function save() { if (!form.title.trim()) return; await runAction(async () => { if (creating) { await createVaultEntry(form); pushToast({ tone: 'success', message: t('common_saved') }); } else { await updateVaultEntry(selected.id, form); pushToast({ tone: 'success', message: t('common_saved') }); } closePopup(); await loadEntries(); }); } function confirmDelete(entry) { openDialog({ tone: 'warning', title: t('common_confirm_delete_title'), message: t('common_confirm_delete_msg'), actions: [ { label: t('common_cancel'), variant: 'standard' }, { label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await deleteVaultEntry(entry.id); await loadEntries(); pushToast({ tone: 'success', message: t('common_saved') }); }), }, ], }); } function copyToClipboard(value) { navigator.clipboard?.writeText(value); pushToast({ tone: 'success', message: t('vault_copied') }); } if (checking) return
{t('common_loading')}
; if (locked) { return (

{hasPinSet ? t('vault_enter_pin_title') : t('vault_create_pin_title')}

{!hasPinSet &&

{t('vault_create_pin_hint')}

} setPin(e.target.value.replace(/\D/g, ''))} onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }} /> {!hasPinSet && ( setPinConfirm(e.target.value.replace(/\D/g, ''))} onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }} /> )} {pinError &&
{pinError}
} {hasPinSet && ( )}
); } return (

{t('nav_passwords')}

{entries.length === 0 && (
{search.trim() ? t('common_search_no_results') : t('common_empty')}
)}
{entries.map((entry) => (
openEntry(entry)}>
e.stopPropagation()}> ( )}> confirmDelete(entry)}>{t('common_delete')}
{entry.title}
{entry.url &&
{entry.url}
}
))}
{editing && (
{Object.entries(ICONS).map(([key, Icon]) => ( ))}
{Object.entries(BRAND_ICONS).map(([key, icon]) => ( ))}
)} setForm((f) => ({ ...f, title: e.target.value }))} /> setForm((f) => ({ ...f, url: e.target.value }))} /> setForm((f) => ({ ...f, username: e.target.value }))} /> {editing ? (
setForm((f) => ({ ...f, password: e.target.value }))} placeholder={creating ? '' : t('vault_password_unchanged_hint')} />
) : (
{!revealedPassword ? ( ) : ( <> )}
)}