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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user