8214df62ae
Amplia el selector de icono de una credencial con ~70 logos reales de marcas conocidas (Google, GitHub, Cloudflare, cPanel, MikroTik, WordPress, etc.), empaquetados localmente desde el paquete simple-icons (dominio publico) y tree-shakeados por Vite. Se mantiene el criterio de privacidad ya establecido: ninguna consulta externa de favicon por entrada. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
446 lines
18 KiB
React
446 lines
18 KiB
React
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 (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill={`#${icon.hex}`}>
|
|
<path d={icon.path} />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function EntryIcon({ iconKey, title, size = 22 }) {
|
|
const Icon = ICONS[iconKey];
|
|
const brand = BRAND_ICONS[iconKey];
|
|
return (
|
|
<div className={`vault-card-icon ${brand ? 'brand' : ''}`}>
|
|
{Icon ? <Icon size={size} /> : brand ? <BrandSvg icon={brand} size={size} /> : <span>{(title || '?').trim().charAt(0).toUpperCase()}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <div className="text-muted">{t('common_loading')}</div>;
|
|
|
|
if (locked) {
|
|
return (
|
|
<div style={{ maxWidth: 360, margin: '60px auto' }} className="panel-card">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
<ShieldAlert size={20} />
|
|
<h3 style={{ margin: 0 }}>{hasPinSet ? t('vault_enter_pin_title') : t('vault_create_pin_title')}</h3>
|
|
</div>
|
|
{!hasPinSet && <p className="text-muted" style={{ fontSize: '.8rem' }}>{t('vault_create_pin_hint')}</p>}
|
|
<Field label={t('vault_pin_label')}>
|
|
<input
|
|
className="input" type="password" inputMode="numeric" maxLength={4} autoFocus
|
|
value={pin} onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
|
/>
|
|
</Field>
|
|
{!hasPinSet && (
|
|
<Field label={t('vault_pin_confirm_label')}>
|
|
<input
|
|
className="input" type="password" inputMode="numeric" maxLength={4}
|
|
value={pinConfirm} onChange={(e) => setPinConfirm(e.target.value.replace(/\D/g, ''))}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
|
/>
|
|
</Field>
|
|
)}
|
|
{pinError && <div className="login-error">{pinError}</div>}
|
|
<Button variant="accent" onClick={submitPin} disabled={busy} style={{ width: '100%', marginTop: 8 }}>
|
|
{hasPinSet ? t('vault_unlock') : t('vault_create_pin_submit')}
|
|
</Button>
|
|
{hasPinSet && (
|
|
<button type="button" className="link-button" style={{ marginTop: 10 }} onClick={handleForgotPin}>
|
|
{t('vault_forgot_pin')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="toolbar">
|
|
<h2 className="page-title mt-0" style={{ marginBottom: 0 }}>{t('nav_passwords')}</h2>
|
|
<SearchInput value={search} onChange={setSearch} />
|
|
<div style={{ flex: 1 }} />
|
|
<Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button>
|
|
<Button variant="accent" icon={Plus} onClick={openCreate}>{t('vault_new_entry')}</Button>
|
|
</div>
|
|
|
|
{entries.length === 0 && (
|
|
<div className="text-muted">{search.trim() ? t('common_search_no_results') : t('common_empty')}</div>
|
|
)}
|
|
|
|
<div className="vault-grid">
|
|
{entries.map((entry) => (
|
|
<div key={entry.id} className="vault-card" onClick={() => openEntry(entry)}>
|
|
<div className="vault-card-menu" onClick={(e) => e.stopPropagation()}>
|
|
<Flyout trigger={({ onClick }) => (
|
|
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical size={16} /></button>
|
|
)}>
|
|
<FlyoutItem icon={Trash2} danger onClick={() => confirmDelete(entry)}>{t('common_delete')}</FlyoutItem>
|
|
</Flyout>
|
|
</div>
|
|
<EntryIcon iconKey={entry.icon_key} title={entry.title} />
|
|
<div className="vault-card-title">{entry.title}</div>
|
|
{entry.url && <div className="vault-card-url">{entry.url}</div>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<DetailPopup
|
|
open={creating || !!selected}
|
|
title={creating ? t('vault_new_entry') : (selected?.title || '')}
|
|
onClose={closePopup}
|
|
width={480}
|
|
height={640}
|
|
>
|
|
{editing && (
|
|
<Field label={t('vault_field_icon')}>
|
|
<div className="vault-icon-picker">
|
|
<button
|
|
type="button"
|
|
className={`vault-icon-option ${!form.iconKey ? 'selected' : ''}`}
|
|
title={t('vault_icon_auto')}
|
|
onClick={() => setForm((f) => ({ ...f, iconKey: '' }))}
|
|
>
|
|
{(form.title || '?').trim().charAt(0).toUpperCase() || 'A'}
|
|
</button>
|
|
{Object.entries(ICONS).map(([key, Icon]) => (
|
|
<button
|
|
type="button"
|
|
key={key}
|
|
className={`vault-icon-option ${form.iconKey === key ? 'selected' : ''}`}
|
|
onClick={() => setForm((f) => ({ ...f, iconKey: key }))}
|
|
>
|
|
<Icon size={18} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="vault-icon-picker vault-icon-picker-brands">
|
|
{Object.entries(BRAND_ICONS).map(([key, icon]) => (
|
|
<button
|
|
type="button"
|
|
key={key}
|
|
className={`vault-icon-option brand ${form.iconKey === key ? 'selected' : ''}`}
|
|
title={icon.title}
|
|
onClick={() => setForm((f) => ({ ...f, iconKey: key }))}
|
|
>
|
|
<BrandSvg icon={icon} size={16} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Field>
|
|
)}
|
|
|
|
<Field label={t('vault_field_title')}>
|
|
<input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} />
|
|
</Field>
|
|
<Field label={t('vault_field_url')}>
|
|
<input className="input" value={form.url} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} />
|
|
</Field>
|
|
<Field label={t('vault_field_username')}>
|
|
<input className="input" value={form.username} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} />
|
|
</Field>
|
|
|
|
<Field label={t('vault_field_password')}>
|
|
{editing ? (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<PasswordInput
|
|
value={form.password}
|
|
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
|
placeholder={creating ? '' : t('vault_password_unchanged_hint')}
|
|
/>
|
|
</div>
|
|
<Button variant="standard" onClick={() => setForm((f) => ({ ...f, password: randomPassword() }))}>{t('vault_generate')}</Button>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<input className="input" type={passwordVisible ? 'text' : 'password'} value={passwordVisible ? (revealedPassword || '') : '••••••••'} disabled readOnly style={{ flex: 1 }} />
|
|
{!revealedPassword ? (
|
|
<button className="icon-btn" title={t('vault_reveal')} onClick={reveal}><Eye size={16} /></button>
|
|
) : (
|
|
<>
|
|
<button className="icon-btn" onClick={() => setPasswordVisible((v) => !v)}>{passwordVisible ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
|
<button className="icon-btn" title={t('vault_copy')} onClick={() => copyToClipboard(revealedPassword)}><Copy size={16} /></button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Field>
|
|
|
|
<Field label={t('vault_field_notes')}>
|
|
<textarea className="textarea" rows={3} disabled={!editing} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
|
</Field>
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
{editing ? (
|
|
<>
|
|
<Button variant="accent" icon={Save} onClick={save} disabled={!form.title.trim()}>{t('common_save')}</Button>
|
|
<Button variant="standard" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
|
|
</>
|
|
) : (
|
|
<Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button>
|
|
)}
|
|
</div>
|
|
</DetailPopup>
|
|
|
|
<DetailPopup open={changingPin} title={t('vault_change_pin')} onClose={() => setChangingPin(false)} width={360} height={360}>
|
|
<Field label={t('vault_pin_current_label')}>
|
|
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.current} onChange={(e) => setPinChangeForm((f) => ({ ...f, current: e.target.value.replace(/\D/g, '') }))} />
|
|
</Field>
|
|
<Field label={t('vault_pin_label')}>
|
|
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.next} onChange={(e) => setPinChangeForm((f) => ({ ...f, next: e.target.value.replace(/\D/g, '') }))} />
|
|
</Field>
|
|
<Field label={t('vault_pin_confirm_label')}>
|
|
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.next2} onChange={(e) => setPinChangeForm((f) => ({ ...f, next2: e.target.value.replace(/\D/g, '') }))} />
|
|
</Field>
|
|
{pinChangeError && <div className="login-error">{pinChangeError}</div>}
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
<Button variant="accent" onClick={submitChangePin}>{t('common_save')}</Button>
|
|
<Button variant="standard" onClick={() => setChangingPin(false)}>{t('common_cancel')}</Button>
|
|
</div>
|
|
</DetailPopup>
|
|
</div>
|
|
);
|
|
}
|