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:
@@ -128,6 +128,11 @@
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
### Changed
|
||||
- "Contraseñas" vuelve a pedir el PIN cada vez que se sale del menú (a otro, o cerrando la
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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`;
|
||||
|
||||
export async function findByUser(userId) {
|
||||
@@ -16,19 +16,19 @@ export async function findById(id) {
|
||||
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(
|
||||
`INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[userId, title, url || null, username || null, passwordEnc || null, notesEnc || null]
|
||||
`INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc, icon_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId, title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null]
|
||||
);
|
||||
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(
|
||||
`UPDATE vault_entries SET title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ? WHERE id = ?`,
|
||||
[title, url || null, username || null, passwordEnc || null, notesEnc || null, 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, iconKey || null, id]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@ import { sendMail } from '../../utils/mailer.js';
|
||||
const PIN_RE = /^\d{4}$/;
|
||||
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() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
@@ -54,20 +64,24 @@ export async function createEntry(payload, currentUser) {
|
||||
username: payload.username?.trim(),
|
||||
passwordEnc: payload.password ? encrypt(payload.password) : 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 });
|
||||
return stripSensitive(entry);
|
||||
}
|
||||
|
||||
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');
|
||||
// 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, {
|
||||
title: payload.title.trim(),
|
||||
url: payload.url?.trim(),
|
||||
username: payload.username?.trim(),
|
||||
passwordEnc: payload.password ? encrypt(payload.password) : null,
|
||||
notesEnc: payload.notes ? encrypt(payload.notes) : null,
|
||||
passwordEnc: payload.password ? encrypt(payload.password) : entry.password_enc,
|
||||
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 });
|
||||
return stripSensitive(updated);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -208,6 +208,9 @@ export const messages = {
|
||||
vault_field_username: 'Usuario',
|
||||
vault_field_password: 'Contraseña',
|
||||
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_copy: 'Copiar',
|
||||
vault_copied: 'Copiado al portapapeles',
|
||||
@@ -535,6 +538,9 @@ export const messages = {
|
||||
vault_field_username: 'Username',
|
||||
vault_field_password: 'Password',
|
||||
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_copy: 'Copy',
|
||||
vault_copied: 'Copied to clipboard',
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { useAction } from '../../hooks/useAsync.js';
|
||||
import {
|
||||
getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin, setVaultToken,
|
||||
listVaultEntries, getVaultEntry, createVaultEntry, updateVaultEntry, deleteVaultEntry,
|
||||
} from '../../services/vault.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
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';
|
||||
|
||||
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
|
||||
|
||||
// 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) {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
|
||||
const bytes = crypto.getRandomValues(new Uint32Array(length));
|
||||
@@ -128,7 +151,7 @@ export default function ContrasenasPage() {
|
||||
|
||||
function openEntry(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);
|
||||
setPasswordVisible(false);
|
||||
setEditing(false);
|
||||
@@ -209,13 +232,6 @@ export default function ContrasenasPage() {
|
||||
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 (locked) {
|
||||
@@ -264,23 +280,57 @@ export default function ContrasenasPage() {
|
||||
<Button variant="accent" icon={Plus} onClick={openCreate}>{t('vault_new_entry')}</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
columns={columns}
|
||||
rows={entries}
|
||||
getRowKey={(r) => r.id}
|
||||
onRowClick={openEntry}
|
||||
rowActions={(r) => [
|
||||
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) },
|
||||
]}
|
||||
/>
|
||||
{entries.length === 0 && <div className="text-muted">{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={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')}>
|
||||
<input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} />
|
||||
</Field>
|
||||
@@ -295,7 +345,11 @@ export default function ContrasenasPage() {
|
||||
{editing ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<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>
|
||||
<Button variant="standard" onClick={() => setForm((f) => ({ ...f, password: randomPassword() }))}>{t('vault_generate')}</Button>
|
||||
</div>
|
||||
|
||||
@@ -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: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) ---
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user