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 ? (
) : (
<>
>
)}
)}
{editing ? (
<>
>
) : (
)}
setChangingPin(false)} width={360} height={360}>
setPinChangeForm((f) => ({ ...f, current: e.target.value.replace(/\D/g, '') }))} />
setPinChangeForm((f) => ({ ...f, next: e.target.value.replace(/\D/g, '') }))} />
setPinChangeForm((f) => ({ ...f, next2: e.target.value.replace(/\D/g, '') }))} />
{pinChangeError && {pinChangeError}
}
);
}