diff --git a/CHANGELOG.md b/CHANGELOG.md index a80290d..21a0097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog - project.linkedcorp.net +## [2.8.0] - 2026-10-01 +### Added +- Buscador en "Contraseñas": filtra las tarjetas mientras escribís, por nombre, usuario, enlace + o **notas** — las notas están cifradas, así que se descifran solo en el servidor para comparar + contra la búsqueda; el texto nunca viaja de vuelta al navegador salvo que coincida y entres a + ver esa entrada puntual. + ## [2.7.2] - 2026-10-01 ### Changed - "Contraseñas" pasa de tabla a tarjetas: cada una muestra un ícono (a elección, de un catálogo diff --git a/VERSION b/VERSION index 37c2961..834f262 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.2 +2.8.0 diff --git a/backend/package.json b/backend/package.json index 1128207..586433f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.7.2", + "version": "2.8.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/vault/controller.js b/backend/src/modules/vault/controller.js index ec7e99c..d5a24c1 100644 --- a/backend/src/modules/vault/controller.js +++ b/backend/src/modules/vault/controller.js @@ -30,7 +30,7 @@ export const postResetPin = asyncHandler(async (req, res) => { }); export const getEntries = asyncHandler(async (req, res) => { - ok(res, await service.listEntries(req.currentUser)); + ok(res, await service.listEntries(req.currentUser, req.query.q)); }); export const getEntry = asyncHandler(async (req, res) => { diff --git a/backend/src/modules/vault/repository.js b/backend/src/modules/vault/repository.js index 6c68023..b59c9c8 100644 --- a/backend/src/modules/vault/repository.js +++ b/backend/src/modules/vault/repository.js @@ -11,6 +11,16 @@ export async function findByUser(userId) { return rows; } +// Igual que findByUser pero con notes_enc -- SOLO para que el service pueda comparar el texto de +// las notas contra la busqueda; nunca se devuelve notes_enc en la respuesta al cliente. +export async function findByUserForSearch(userId) { + const [rows] = await pool.query( + `SELECT ${LIST_SELECT}, notes_enc FROM vault_entries WHERE user_id = ? ORDER BY title`, + [userId] + ); + return rows; +} + export async function findById(id) { const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]); return rows[0] || null; diff --git a/backend/src/modules/vault/service.js b/backend/src/modules/vault/service.js index c88061a..cfac715 100644 --- a/backend/src/modules/vault/service.js +++ b/backend/src/modules/vault/service.js @@ -44,9 +44,26 @@ function decryptEntry(entry) { }; } -export async function listEntries(currentUser) { - const rows = await repo.findByUser(currentUser.id); - return rows; // sin password/notes -- la lista nunca trae datos sensibles +// Buscar por nombre/usuario/enlace es trivial (van en texto plano). Las notas SI estan cifradas +// -- se descifran aca mismo, en el servidor, solo para comparar contra la busqueda; el contenido +// nunca sale hacia el cliente (se saca con stripSensitive antes de devolver, igual que siempre). +// No se audita como "view": nunca se le muestra el texto de la nota al usuario, solo si matchea. +export async function listEntries(currentUser, query) { + const q = query?.trim().toLowerCase(); + if (!q) return repo.findByUser(currentUser.id); + + const rows = await repo.findByUserForSearch(currentUser.id); + return rows + .filter((row) => { + if ([row.title, row.url, row.username].some((v) => v && v.toLowerCase().includes(q))) return true; + if (!row.notes_enc) return false; + try { + return decrypt(row.notes_enc).toLowerCase().includes(q); + } catch { + return false; + } + }) + .map(stripSensitive); } // "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita. diff --git a/frontend/package.json b/frontend/package.json index b49eed3..21396a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.7.2", + "version": "2.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx index f5e724d..900ea8c 100644 --- a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx +++ b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx @@ -7,6 +7,7 @@ import { } 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, @@ -16,6 +17,7 @@ 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'; 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 @@ -54,6 +56,8 @@ export default function ContrasenasPage() { 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(''); @@ -71,8 +75,8 @@ export default function ContrasenasPage() { const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' }); const [pinChangeError, setPinChangeError] = useState(''); - async function loadEntries() { - const data = await listVaultEntries(); + async function loadEntries(query) { + const data = await listVaultEntries(query); setEntries(data); setLocked(false); } @@ -94,6 +98,15 @@ export default function ContrasenasPage() { 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. @@ -275,12 +288,15 @@ export default function ContrasenasPage() {