feat: version 2.8.0 -- buscador en Contraseñas (nombre/usuario/enlace/notas)
Filtra las tarjetas mientras se escribe. Nombre/usuario/enlace se comparan en texto plano; las notas (cifradas) se descifran solo en el servidor para la comparacion -- el contenido nunca viaja de vuelta al cliente salvo al revelar esa entrada puntual. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.7.2] - 2026-10-01
|
||||||
### Changed
|
### Changed
|
||||||
- "Contraseñas" pasa de tabla a tarjetas: cada una muestra un ícono (a elección, de un catálogo
|
- "Contraseñas" pasa de tabla a tarjetas: cada una muestra un ícono (a elección, de un catálogo
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.7.2",
|
"version": "2.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const postResetPin = asyncHandler(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getEntries = 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) => {
|
export const getEntry = asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ export async function findByUser(userId) {
|
|||||||
return rows;
|
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) {
|
export async function findById(id) {
|
||||||
const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]);
|
const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
|
|||||||
@@ -44,9 +44,26 @@ function decryptEntry(entry) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listEntries(currentUser) {
|
// Buscar por nombre/usuario/enlace es trivial (van en texto plano). Las notas SI estan cifradas
|
||||||
const rows = await repo.findByUser(currentUser.id);
|
// -- se descifran aca mismo, en el servidor, solo para comparar contra la busqueda; el contenido
|
||||||
return rows; // sin password/notes -- la lista nunca trae datos sensibles
|
// 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.
|
// "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.7.2",
|
"version": "2.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAction } from '../../hooks/useAsync.js';
|
import { useAction } from '../../hooks/useAsync.js';
|
||||||
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
|
||||||
import {
|
import {
|
||||||
getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin, setVaultToken,
|
getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin, setVaultToken,
|
||||||
listVaultEntries, getVaultEntry, createVaultEntry, updateVaultEntry, deleteVaultEntry,
|
listVaultEntries, getVaultEntry, createVaultEntry, updateVaultEntry, deleteVaultEntry,
|
||||||
@@ -16,6 +17,7 @@ import { Field } from '../../components/Field.jsx';
|
|||||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
import { Flyout, FlyoutItem } from '../../components/Flyout.jsx';
|
import { Flyout, FlyoutItem } from '../../components/Flyout.jsx';
|
||||||
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
|
|
||||||
const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '', iconKey: '' };
|
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
|
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 [checking, setChecking] = useState(true);
|
||||||
const [hasPinSet, setHasPinSet] = useState(null);
|
const [hasPinSet, setHasPinSet] = useState(null);
|
||||||
const [entries, setEntries] = useState(null);
|
const [entries, setEntries] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debouncedSearch = useDebouncedValue(search, 300);
|
||||||
|
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState('');
|
||||||
const [pinConfirm, setPinConfirm] = useState('');
|
const [pinConfirm, setPinConfirm] = useState('');
|
||||||
@@ -71,8 +75,8 @@ export default function ContrasenasPage() {
|
|||||||
const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' });
|
const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' });
|
||||||
const [pinChangeError, setPinChangeError] = useState('');
|
const [pinChangeError, setPinChangeError] = useState('');
|
||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries(query) {
|
||||||
const data = await listVaultEntries();
|
const data = await listVaultEntries(query);
|
||||||
setEntries(data);
|
setEntries(data);
|
||||||
setLocked(false);
|
setLocked(false);
|
||||||
}
|
}
|
||||||
@@ -94,6 +98,15 @@ export default function ContrasenasPage() {
|
|||||||
|
|
||||||
useEffect(() => { checkAccess(); }, []);
|
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 --
|
// 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
|
// 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.
|
// PIN de nuevo, sin importar cuanto haya durado la sesion del sistema.
|
||||||
@@ -275,12 +288,15 @@ export default function ContrasenasPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<h2 className="page-title mt-0" style={{ marginBottom: 0 }}>{t('nav_passwords')}</h2>
|
<h2 className="page-title mt-0" style={{ marginBottom: 0 }}>{t('nav_passwords')}</h2>
|
||||||
|
<SearchInput value={search} onChange={setSearch} />
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button>
|
<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>
|
<Button variant="accent" icon={Plus} onClick={openCreate}>{t('vault_new_entry')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entries.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
|
{entries.length === 0 && (
|
||||||
|
<div className="text-muted">{search.trim() ? t('common_search_no_results') : t('common_empty')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="vault-grid">
|
<div className="vault-grid">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const changeVaultPin = (currentPin, newPin) => api.post('/vault/change-pi
|
|||||||
export const forgotVaultPin = () => api.post('/vault/forgot-pin');
|
export const forgotVaultPin = () => api.post('/vault/forgot-pin');
|
||||||
export const resetVaultPin = (token, newPin) => api.post('/vault/reset-pin', { token, newPin });
|
export const resetVaultPin = (token, newPin) => api.post('/vault/reset-pin', { token, newPin });
|
||||||
|
|
||||||
export const listVaultEntries = () => api.get('/vault/entries', vaultHeaders());
|
export const listVaultEntries = (query) => api.get(`/vault/entries${query ? `?q=${encodeURIComponent(query)}` : ''}`, vaultHeaders());
|
||||||
export const getVaultEntry = (id) => api.get(`/vault/entries/${id}`, vaultHeaders());
|
export const getVaultEntry = (id) => api.get(`/vault/entries/${id}`, vaultHeaders());
|
||||||
export const createVaultEntry = (data) => api.post('/vault/entries', data, vaultHeaders());
|
export const createVaultEntry = (data) => api.post('/vault/entries', data, vaultHeaders());
|
||||||
export const updateVaultEntry = (id, data) => api.put(`/vault/entries/${id}`, data, vaultHeaders());
|
export const updateVaultEntry = (id, data) => api.put(`/vault/entries/${id}`, data, vaultHeaders());
|
||||||
|
|||||||
Reference in New Issue
Block a user