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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "2.7.1",
"version": "2.7.2",
"type": "module",
"scripts": {
"dev": "vite",
+6
View File
@@ -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>
+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: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