diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 1a461fb..046a767 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e278ca0..a80290d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 860487c..37c2961 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.1 +2.7.2 diff --git a/backend/database/migrations/018_vault_icon.sql b/backend/database/migrations/018_vault_icon.sql new file mode 100644 index 0000000..d81759a --- /dev/null +++ b/backend/database/migrations/018_vault_icon.sql @@ -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; diff --git a/backend/package.json b/backend/package.json index ad12616..1128207 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.7.1", + "version": "2.7.2", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/vault/repository.js b/backend/src/modules/vault/repository.js index 9020cfe..6c68023 100644 --- a/backend/src/modules/vault/repository.js +++ b/backend/src/modules/vault/repository.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); } diff --git a/backend/src/modules/vault/service.js b/backend/src/modules/vault/service.js index b180130..c88061a 100644 --- a/backend/src/modules/vault/service.js +++ b/backend/src/modules/vault/service.js @@ -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); diff --git a/frontend/package.json b/frontend/package.json index dfc952f..b49eed3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.7.1", + "version": "2.7.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 21d789f..e065b7e 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -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', diff --git a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx index 6266803..f5e724d 100644 --- a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx +++ b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx @@ -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 ( +
+ {Icon ? : {(title || '?').trim().charAt(0).toUpperCase()}} +
+ ); +} + 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 ? e.stopPropagation()} className="text-link">{r.url} : '—' }, - { 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
{t('common_loading')}
; if (locked) { @@ -264,23 +280,57 @@ export default function ContrasenasPage() { - r.id} - onRowClick={openEntry} - rowActions={(r) => [ - { label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) }, - ]} - /> + {entries.length === 0 &&
{t('common_empty')}
} + +
+ {entries.map((entry) => ( +
openEntry(entry)}> +
e.stopPropagation()}> + ( + + )}> + confirmDelete(entry)}>{t('common_delete')} + +
+ +
{entry.title}
+ {entry.url &&
{entry.url}
} +
+ ))} +
+ {editing && ( + +
+ + {Object.entries(ICONS).map(([key, Icon]) => ( + + ))} +
+
+ )} + setForm((f) => ({ ...f, title: e.target.value }))} /> @@ -295,7 +345,11 @@ export default function ContrasenasPage() { {editing ? (
- setForm((f) => ({ ...f, password: e.target.value }))} /> + setForm((f) => ({ ...f, password: e.target.value }))} + placeholder={creating ? '' : t('vault_password_unchanged_hint')} + />
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 195a3c0..212681e 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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