feat: version 2.7.2 -- tarjetas con icono en Contraseñas + fix borrado de password al editar

Listado de Contraseñas pasa de tabla a tarjetas: icono elegible (
catalogo fijo, sin servicios externos de favicon por privacidad) o
inicial del nombre como fallback, junto con nombre y enlace.

Fix real: editar una credencial sin revelar la contraseña antes la
dejaba vacia al guardar -- ahora se conserva la que ya estaba si no
se escribe una nueva.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-01 12:41:22 -05:00
parent 28e339f19a
commit 127ca2ba7e
11 changed files with 161 additions and 36 deletions
+5
View File
@@ -128,6 +128,11 @@
candado, no una recuperación de datos. Cada vez que se revela/crea/edita/elimina una entrada, o candado, no una recuperación de datos. Cada vez que se revela/crea/edita/elimina una entrada, o
se desbloquea el candado, queda en `audit_log`. Compartir una entrada con otro usuario queda se desbloquea el candado, queda en `audit_log`. Compartir una entrada con otro usuario queda
para una fase 2 (no implementado). para una fase 2 (no implementado).
- `vault_entries.icon_key` (desde v2.7.2): ícono elegido por el usuario para la tarjeta (catálogo
fijo, validado también en el backend — `vault/service.js#ICON_KEYS` debe coincidir siempre con
el del frontend). A propósito NO se usa ningún servicio externo de favicon (revelaría a
terceros qué URLs internas tiene guardadas cada usuario). `NULL` = sin elegir, el frontend
muestra la inicial del nombre.
## Riesgos o decisiones ## Riesgos o decisiones
+10
View File
@@ -1,5 +1,15 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [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
fijo — sin usar ningún servicio externo de favicon, por privacidad) o la inicial del nombre si
no se elige ninguno, junto con el nombre y el enlace.
### Fixed
- **Bug real**: editar una credencial sin antes revelar la contraseña la dejaba vacía al guardar
(se borraba sin querer). Ahora, si no se escribe una contraseña nueva al editar, se conserva la
que ya estaba guardada.
## [2.7.1] - 2026-10-01 ## [2.7.1] - 2026-10-01
### Changed ### Changed
- "Contraseñas" vuelve a pedir el PIN cada vez que se sale del menú (a otro, o cerrando la - "Contraseñas" vuelve a pedir el PIN cada vez que se sale del menú (a otro, o cerrando la
+1 -1
View File
@@ -1 +1 @@
2.7.1 2.7.2
@@ -0,0 +1,5 @@
-- Icono elegible por el usuario para cada credencial guardada (ver frontend: catalogo fijo de
-- iconos, ninguna llamada a servicios externos de favicon por privacidad -- son urls internas).
-- NULL = sin elegir, el frontend muestra un avatar con la inicial del nombre.
ALTER TABLE vault_entries
ADD COLUMN IF NOT EXISTS icon_key VARCHAR(30) NULL;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "2.7.1", "version": "2.7.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+8 -8
View File
@@ -1,6 +1,6 @@
import { pool } from '../../config/db.js'; import { pool } from '../../config/db.js';
const LIST_SELECT = 'id, user_id, title, url, username, created_at, updated_at'; const LIST_SELECT = 'id, user_id, title, url, username, icon_key, created_at, updated_at';
const FULL_SELECT = `${LIST_SELECT}, password_enc, notes_enc`; const FULL_SELECT = `${LIST_SELECT}, password_enc, notes_enc`;
export async function findByUser(userId) { export async function findByUser(userId) {
@@ -16,19 +16,19 @@ export async function findById(id) {
return rows[0] || null; return rows[0] || null;
} }
export async function create(userId, { title, url, username, passwordEnc, notesEnc }) { export async function create(userId, { title, url, username, passwordEnc, notesEnc, iconKey }) {
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc) `INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc, icon_key)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
[userId, title, url || null, username || null, passwordEnc || null, notesEnc || null] [userId, title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null]
); );
return findById(result.insertId); return findById(result.insertId);
} }
export async function update(id, { title, url, username, passwordEnc, notesEnc }) { export async function update(id, { title, url, username, passwordEnc, notesEnc, iconKey }) {
await pool.query( await pool.query(
`UPDATE vault_entries SET title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ? WHERE id = ?`, `UPDATE vault_entries SET title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ?, icon_key = ? WHERE id = ?`,
[title, url || null, username || null, passwordEnc || null, notesEnc || null, id] [title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null, id]
); );
return findById(id); return findById(id);
} }
+17 -3
View File
@@ -11,6 +11,16 @@ import { sendMail } from '../../utils/mailer.js';
const PIN_RE = /^\d{4}$/; const PIN_RE = /^\d{4}$/;
const VAULT_TOKEN_EXPIRES = '30m'; const VAULT_TOKEN_EXPIRES = '30m';
// Catalogo fijo (debe coincidir con el del frontend, ContrasenasPage.jsx#ICONS) -- nunca se
// acepta un valor arbitrario, para no guardar basura ni abrir una via de inyeccion rara.
const ICON_KEYS = new Set([
'mail', 'globe', 'server', 'database', 'cloud', 'shield', 'lock', 'wifi',
'card', 'building', 'printer', 'phone', 'key', 'disk', 'router', 'user',
]);
function sanitizeIconKey(key) {
return ICON_KEYS.has(key) ? key : null;
}
function newToken() { function newToken() {
return crypto.randomBytes(32).toString('hex'); return crypto.randomBytes(32).toString('hex');
} }
@@ -54,20 +64,24 @@ export async function createEntry(payload, currentUser) {
username: payload.username?.trim(), username: payload.username?.trim(),
passwordEnc: payload.password ? encrypt(payload.password) : null, passwordEnc: payload.password ? encrypt(payload.password) : null,
notesEnc: payload.notes ? encrypt(payload.notes) : null, notesEnc: payload.notes ? encrypt(payload.notes) : null,
iconKey: sanitizeIconKey(payload.iconKey),
}); });
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id }); await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id });
return stripSensitive(entry); return stripSensitive(entry);
} }
export async function updateEntry(id, payload, currentUser) { export async function updateEntry(id, payload, currentUser) {
await getOwnEntryOr404(id, currentUser); const entry = await getOwnEntryOr404(id, currentUser);
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio'); if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
// Si no viene password/notes nuevo (ej. se edito solo el nombre sin pasar por "Revelar"
// primero), se conserva lo que ya estaba guardado -- nunca se borra por venir vacio.
const updated = await repo.update(id, { const updated = await repo.update(id, {
title: payload.title.trim(), title: payload.title.trim(),
url: payload.url?.trim(), url: payload.url?.trim(),
username: payload.username?.trim(), username: payload.username?.trim(),
passwordEnc: payload.password ? encrypt(payload.password) : null, passwordEnc: payload.password ? encrypt(payload.password) : entry.password_enc,
notesEnc: payload.notes ? encrypt(payload.notes) : null, notesEnc: payload.notes !== undefined && payload.notes !== '' ? encrypt(payload.notes) : entry.notes_enc,
iconKey: sanitizeIconKey(payload.iconKey),
}); });
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id }); await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id });
return stripSensitive(updated); return stripSensitive(updated);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "2.7.1", "version": "2.7.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6
View File
@@ -208,6 +208,9 @@ export const messages = {
vault_field_username: 'Usuario', vault_field_username: 'Usuario',
vault_field_password: 'Contraseña', vault_field_password: 'Contraseña',
vault_field_notes: 'Notas', vault_field_notes: 'Notas',
vault_field_icon: 'Ícono',
vault_icon_auto: 'Automático (inicial del nombre)',
vault_password_unchanged_hint: 'Dejar en blanco para no cambiarla',
vault_reveal: 'Revelar', vault_reveal: 'Revelar',
vault_copy: 'Copiar', vault_copy: 'Copiar',
vault_copied: 'Copiado al portapapeles', vault_copied: 'Copiado al portapapeles',
@@ -535,6 +538,9 @@ export const messages = {
vault_field_username: 'Username', vault_field_username: 'Username',
vault_field_password: 'Password', vault_field_password: 'Password',
vault_field_notes: 'Notes', vault_field_notes: 'Notes',
vault_field_icon: 'Icon',
vault_icon_auto: 'Automatic (name initial)',
vault_password_unchanged_hint: 'Leave blank to keep it unchanged',
vault_reveal: 'Reveal', vault_reveal: 'Reveal',
vault_copy: 'Copy', vault_copy: 'Copy',
vault_copied: 'Copied to clipboard', vault_copied: 'Copied to clipboard',
@@ -1,21 +1,44 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Plus, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert } from 'lucide-react'; 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 { useUi } from '../../context/ui-context.jsx';
import { useAction } from '../../hooks/useAsync.js'; import { useAction } from '../../hooks/useAsync.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,
} from '../../services/vault.js'; } from '../../services/vault.js';
import { ListView } from '../../components/ListView.jsx';
import { Button } from '../../components/Button.jsx'; import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx'; 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';
const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '' }; 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
// Catalogo fijo de iconos (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 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 EntryIcon({ iconKey, title, size = 22 }) {
const Icon = ICONS[iconKey];
return (
<div className="vault-card-icon">
{Icon ? <Icon size={size} /> : <span>{(title || '?').trim().charAt(0).toUpperCase()}</span>}
</div>
);
}
function randomPassword(length = 20) { function randomPassword(length = 20) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'; const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
const bytes = crypto.getRandomValues(new Uint32Array(length)); const bytes = crypto.getRandomValues(new Uint32Array(length));
@@ -128,7 +151,7 @@ export default function ContrasenasPage() {
function openEntry(entry) { function openEntry(entry) {
setSelected(entry); setSelected(entry);
setForm({ title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '' }); setForm({ title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '', iconKey: entry.icon_key || '' });
setRevealedPassword(null); setRevealedPassword(null);
setPasswordVisible(false); setPasswordVisible(false);
setEditing(false); setEditing(false);
@@ -209,13 +232,6 @@ export default function ContrasenasPage() {
pushToast({ tone: 'success', message: t('vault_copied') }); pushToast({ tone: 'success', message: t('vault_copied') });
} }
const columns = [
{ key: 'title', label: t('vault_field_title') },
{ key: 'url', label: t('vault_field_url'), render: (r) => r.url ? <a href={r.url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} className="text-link">{r.url}</a> : '—' },
{ key: 'username', label: t('vault_field_username'), render: (r) => r.username || '—' },
{ key: 'updated_at', label: t('project_updated_at'), render: (r) => String(r.updated_at).slice(0, 16) },
];
if (checking) return <div className="text-muted">{t('common_loading')}</div>; if (checking) return <div className="text-muted">{t('common_loading')}</div>;
if (locked) { if (locked) {
@@ -264,23 +280,57 @@ export default function ContrasenasPage() {
<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>
<ListView {entries.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
columns={columns}
rows={entries} <div className="vault-grid">
getRowKey={(r) => r.id} {entries.map((entry) => (
onRowClick={openEntry} <div key={entry.id} className="vault-card" onClick={() => openEntry(entry)}>
rowActions={(r) => [ <div className="vault-card-menu" onClick={(e) => e.stopPropagation()}>
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) }, <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 <DetailPopup
open={creating || !!selected} open={creating || !!selected}
title={creating ? t('vault_new_entry') : (selected?.title || '')} title={creating ? t('vault_new_entry') : (selected?.title || '')}
onClose={closePopup} onClose={closePopup}
width={480} width={480}
height={560} 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>
</Field>
)}
<Field label={t('vault_field_title')}> <Field label={t('vault_field_title')}>
<input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} /> <input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} />
</Field> </Field>
@@ -295,7 +345,11 @@ export default function ContrasenasPage() {
{editing ? ( {editing ? (
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} /> <PasswordInput
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
placeholder={creating ? '' : t('vault_password_unchanged_hint')}
/>
</div> </div>
<Button variant="standard" onClick={() => setForm((f) => ({ ...f, password: randomPassword() }))}>{t('vault_generate')}</Button> <Button variant="standard" onClick={() => setForm((f) => ({ ...f, password: randomPassword() }))}>{t('vault_generate')}</Button>
</div> </div>
+31
View File
@@ -356,6 +356,37 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.text-link { color: rgb(var(--accent)); text-decoration: none; } .text-link { color: rgb(var(--accent)); text-decoration: none; }
.text-link:hover { text-decoration: underline; } .text-link:hover { text-decoration: underline; }
/* --- Contraseñas: tarjetas en vez de tabla --- */
.vault-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px;
}
.vault-card {
position: relative; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
border-radius: var(--r-lg); padding: 20px 14px 16px; cursor: pointer;
display: flex; flex-direction: column; align-items: center; text-align: center; gap: 8px;
transition: border-color .15s ease, transform .1s ease;
}
.vault-card:hover { border-color: rgb(var(--accent) / .5); }
.vault-card-menu { position: absolute; top: 6px; right: 6px; }
.vault-card-icon {
width: 48px; height: 48px; border-radius: 999px; background: rgb(var(--accent) / .15);
color: rgb(var(--accent)); display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; font-weight: 700; flex: none;
}
.vault-card-title { font-size: .84rem; font-weight: 600; word-break: break-word; }
.vault-card-url {
font-size: .72rem; color: rgb(var(--muted)); max-width: 100%; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.vault-icon-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.vault-icon-option {
width: 36px; height: 36px; border-radius: 999px; border: 1px solid rgb(var(--line));
background: rgb(var(--field)); color: rgb(var(--body)); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .82rem;
}
.vault-icon-option:hover { border-color: rgb(var(--accent) / .6); }
.vault-icon-option.selected { border-color: rgb(var(--accent)); background: rgb(var(--accent) / .15); color: rgb(var(--accent)); }
/* --- DetailPopup (ver DetailPopup.jsx) --- /* --- DetailPopup (ver DetailPopup.jsx) ---
Al elegir un item de un listado, la lista queda inactiva detras del overlay y el popup Al elegir un item de un listado, la lista queda inactiva detras del overlay y el popup
presenta la pantalla de info/edicion completa. Regla dura: NUNCA se cierra con clic afuera presenta la pantalla de info/edicion completa. Regla dura: NUNCA se cierra con clic afuera