feat: version 2.7.0 -- menu Contraseñas (repositorio de credenciales privado)

Nuevo modulo backend/src/modules/vault/: contraseña y notas cifradas
en el servidor (AES-256-GCM, mismo mecanismo ya usado para la clave
SMTP). Acceso al menu protegido con un PIN de 4 digitos independiente
del login (token corto propio, header X-Vault-Token, nunca el JWT de
sesion). El PIN no participa del cifrado -- cambiarlo o restablecerlo
por correo nunca afecta las contraseñas ya guardadas. Privado por
usuario, sin excepcion. Cada accion sensible (crear/editar/eliminar/
revelar/desbloquear) queda en audit_log. Compartir con otro usuario
queda para una proxima version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-01 12:12:54 -05:00
parent f607455668
commit 0be0712ff0
20 changed files with 879 additions and 10 deletions
+10
View File
@@ -40,6 +40,7 @@
| `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) | | `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) |
| `error_logs` | errores de backend (excepciones 500) y frontend (JS/promesas sin catch), panel "Errores del sistema" en Configuración | | `error_logs` | errores de backend (excepciones 500) y frontend (JS/promesas sin catch), panel "Errores del sistema" en Configuración |
| `notes` | apuntes rápidos personales, siempre privados de cada usuario, menú propio "Notas" | | `notes` | apuntes rápidos personales, siempre privados de cada usuario, menú propio "Notas" |
| `vault_entries` | repositorio de credenciales privado por usuario (menú "Contraseñas"), contraseña/notas cifradas |
## Relaciones clave ## Relaciones clave
@@ -118,6 +119,15 @@
del todo desde el panel ("Limpiar registro"). `severity` (alta/media/baja) nunca se etiqueta a del todo desde el panel ("Limpiar registro"). `severity` (alta/media/baja) nunca se etiqueta a
mano: backend y errores de render de React = alta, errores JS sueltos = media, promesas mano: backend y errores de render de React = alta, errores JS sueltos = media, promesas
rechazadas sin manejar = baja. rechazadas sin manejar = baja.
- `vault_entries` (desde v2.7.0): `password_enc`/`notes_enc` guardan el resultado de `encrypt()`
(AES-256-GCM, `backend/src/utils/crypto.js`, mismo mecanismo ya usado para la clave SMTP) —
nunca texto plano. Privado por usuario, sin excepción (ni superadmin ve lo de otro). El acceso
al menú "Contraseñas" además pide un PIN de 4 dígitos (`users.vault_pin_hash`, bcrypt) — el PIN
es un candado de acceso, NO participa del cifrado (sería débil para eso). Cambiar/resetear el
PIN (incluso por correo, `vault_pin_reset_token`) nunca toca `vault_entries` — es un cambio de
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).
## Riesgos o decisiones ## Riesgos o decisiones
+15
View File
@@ -1,5 +1,20 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [2.7.0] - 2026-10-01
### Added
- Nuevo menú "Contraseñas": repositorio personal de credenciales (nombre, enlace, usuario,
contraseña, notas), siempre privado — nadie más lo ve, ni un superadmin.
- Contraseña y notas cifradas en el servidor (AES-256-GCM, mismo mecanismo ya probado que usa la
clave SMTP) — nunca en texto plano en la base de datos.
- El menú pide un PIN de 4 dígitos para entrar, aparte de la clave de acceso normal al sistema
(que no se toca). El PIN es un candado de acceso, no la clave de cifrado. Se puede cambiar
desde el propio menú, o restablecer por correo si se olvida — restablecerlo nunca borra ni
afecta las contraseñas ya guardadas, solo cambia el candado.
- Generador de contraseñas al crear una entrada nueva.
- Queda registrado en el log de auditoría cada vez que se crea, edita, elimina o **revela** una
contraseña guardada, y cada desbloqueo del candado.
- Compartir una entrada con otro usuario queda para una próxima versión.
## [2.6.1] - 2026-09-30 ## [2.6.1] - 2026-09-30
### Fixed ### Fixed
- **Bug real en el botón "Actualizar" (auto-actualización)**: el paso final reiniciaba backend y - **Bug real en el botón "Actualizar" (auto-actualización)**: el paso final reiniciaba backend y
+1 -1
View File
@@ -1 +1 @@
2.6.1 2.7.0
+24
View File
@@ -0,0 +1,24 @@
-- Repositorio de credenciales privado por usuario ("Contraseñas"). Idempotente.
-- password_enc/notes_enc guardan el resultado de encrypt() (AES-256-GCM, backend/src/utils/crypto.js),
-- nunca texto plano. Privado por usuario -- nadie mas lo ve, ni siquiera un superadmin.
CREATE TABLE IF NOT EXISTS vault_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(160) NOT NULL,
url VARCHAR(500) NULL,
username VARCHAR(160) NULL,
password_enc TEXT NULL,
notes_enc TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_vault_entries_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE INDEX IF NOT EXISTS idx_vault_entries_user ON vault_entries(user_id);
-- PIN de acceso al modulo (candado, NO participa del cifrado -- 4 digitos seria debil para eso).
-- Independiente del password_hash de login: cambiarlo/resetearlo nunca toca vault_entries.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS vault_pin_hash VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS vault_pin_reset_token VARCHAR(128) NULL,
ADD COLUMN IF NOT EXISTS vault_pin_reset_token_expires DATETIME NULL;
CREATE INDEX IF NOT EXISTS idx_users_vault_pin_reset_token ON users(vault_pin_reset_token);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "2.6.1", "version": "2.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+2
View File
@@ -23,6 +23,7 @@ import searchRoutes from './modules/search/routes.js';
import updaterRoutes from './modules/updater/routes.js'; import updaterRoutes from './modules/updater/routes.js';
import errorLogsRoutes from './modules/errorLogs/routes.js'; import errorLogsRoutes from './modules/errorLogs/routes.js';
import notesRoutes from './modules/notes/routes.js'; import notesRoutes from './modules/notes/routes.js';
import vaultRoutes from './modules/vault/routes.js';
export const app = express(); export const app = express();
@@ -50,6 +51,7 @@ app.use('/api/search', searchRoutes);
app.use('/api/updater', updaterRoutes); app.use('/api/updater', updaterRoutes);
app.use('/api/error-logs', errorLogsRoutes); app.use('/api/error-logs', errorLogsRoutes);
app.use('/api/notes', notesRoutes); app.use('/api/notes', notesRoutes);
app.use('/api/vault', vaultRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica). // especifico registrado despues (ver ESTADO.md, deuda tecnica).
+51
View File
@@ -0,0 +1,51 @@
import { ok, asyncHandler } from '../../utils/http.js';
import * as service from './service.js';
export const getStatus = asyncHandler(async (req, res) => {
ok(res, await service.getStatus(req.currentUser));
});
export const postPin = asyncHandler(async (req, res) => {
await service.createPin(req.body.pin, req.currentUser);
ok(res, { created: true });
});
export const postUnlock = asyncHandler(async (req, res) => {
ok(res, await service.unlock(req.body.pin, req.currentUser));
});
export const postChangePin = asyncHandler(async (req, res) => {
await service.changePin(req.body.currentPin, req.body.newPin, req.currentUser);
ok(res, { changed: true });
});
export const postForgotPin = asyncHandler(async (req, res) => {
await service.forgotPin(req.currentUser);
ok(res, { sent: true });
});
export const postResetPin = asyncHandler(async (req, res) => {
await service.resetPin(req.body.token, req.body.newPin);
ok(res, { reset: true });
});
export const getEntries = asyncHandler(async (req, res) => {
ok(res, await service.listEntries(req.currentUser));
});
export const getEntry = asyncHandler(async (req, res) => {
ok(res, await service.getEntry(Number(req.params.id), req.currentUser));
});
export const postEntry = asyncHandler(async (req, res) => {
ok(res, await service.createEntry(req.body, req.currentUser), 201);
});
export const putEntry = asyncHandler(async (req, res) => {
ok(res, await service.updateEntry(Number(req.params.id), req.body, req.currentUser));
});
export const deleteEntry = asyncHandler(async (req, res) => {
await service.removeEntry(Number(req.params.id), req.currentUser);
ok(res, { deleted: true });
});
+21
View File
@@ -0,0 +1,21 @@
import jwt from 'jsonwebtoken';
import { env } from '../../config/env.js';
import { fail } from '../../utils/http.js';
// Candado de acceso a /api/vault/entries*: token corto y propio (scope:'vault'), NUNCA el JWT de
// login -- se pide recien despues de verificar el PIN (ver service.js#unlock). Va en un header
// propio para no mezclarse con el Authorization normal, que sigue intacto.
export function requireVaultUnlock(req, res, next) {
const token = req.headers['x-vault-token'];
if (!token) return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN');
let payload;
try {
payload = jwt.verify(token, env.jwtSecret);
} catch {
return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN');
}
if (payload.scope !== 'vault' || payload.sub !== req.currentUser.id) {
return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN');
}
next();
}
+67
View File
@@ -0,0 +1,67 @@
import { pool } from '../../config/db.js';
const LIST_SELECT = 'id, user_id, title, url, username, created_at, updated_at';
const FULL_SELECT = `${LIST_SELECT}, password_enc, notes_enc`;
export async function findByUser(userId) {
const [rows] = await pool.query(
`SELECT ${LIST_SELECT} FROM vault_entries WHERE user_id = ? ORDER BY title`,
[userId]
);
return rows;
}
export async function findById(id) {
const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]);
return rows[0] || null;
}
export async function create(userId, { title, url, username, passwordEnc, notesEnc }) {
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]
);
return findById(result.insertId);
}
export async function update(id, { title, url, username, passwordEnc, notesEnc }) {
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]
);
return findById(id);
}
export async function remove(id) {
await pool.query('DELETE FROM vault_entries WHERE id = ?', [id]);
}
// --- PIN de acceso al modulo (en la tabla users, ver migracion 017) ---
export async function getVaultPinHash(userId) {
const [rows] = await pool.query('SELECT vault_pin_hash FROM users WHERE id = ?', [userId]);
return rows[0]?.vault_pin_hash || null;
}
export async function setVaultPinHash(userId, hash) {
await pool.query(
'UPDATE users SET vault_pin_hash = ?, vault_pin_reset_token = NULL, vault_pin_reset_token_expires = NULL WHERE id = ?',
[hash, userId]
);
}
export async function setPinResetToken(userId, token) {
await pool.query(
'UPDATE users SET vault_pin_reset_token = ?, vault_pin_reset_token_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?',
[token, userId]
);
}
export async function findByPinResetToken(token) {
const [rows] = await pool.query(
'SELECT id FROM users WHERE vault_pin_reset_token = ? AND vault_pin_reset_token_expires > NOW()',
[token]
);
return rows[0] || null;
}
+31
View File
@@ -0,0 +1,31 @@
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth } from '../../middleware/auth.js';
import { requireVaultUnlock } from './middleware.js';
import {
getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin,
getEntries, getEntry, postEntry, putEntry, deleteEntry,
} from './controller.js';
const router = Router();
// PIN de 4 digitos: 10,000 combinaciones -- sin este limiter seria trivial de forzar.
const unlockLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 8, standardHeaders: true, legacyHeaders: false });
// Publico, como auth/reset-password: el link del correo puede abrirse sin sesion activa.
router.post('/reset-pin', unlockLimiter, postResetPin);
router.use(requireAuth);
router.get('/status', getStatus);
router.post('/pin', postPin);
router.post('/unlock', unlockLimiter, postUnlock);
router.post('/change-pin', unlockLimiter, postChangePin);
router.post('/forgot-pin', unlockLimiter, postForgotPin);
router.get('/entries', requireVaultUnlock, getEntries);
router.get('/entries/:id', requireVaultUnlock, getEntry);
router.post('/entries', requireVaultUnlock, postEntry);
router.put('/entries/:id', requireVaultUnlock, putEntry);
router.delete('/entries/:id', requireVaultUnlock, deleteEntry);
export default router;
+144
View File
@@ -0,0 +1,144 @@
import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import * as repo from './repository.js';
import { env } from '../../config/env.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
import { HttpError } from '../../utils/http.js';
import { logAudit } from '../../utils/audit.js';
import { sendMail } from '../../utils/mailer.js';
const PIN_RE = /^\d{4}$/;
const VAULT_TOKEN_EXPIRES = '30m';
function newToken() {
return crypto.randomBytes(32).toString('hex');
}
async function getOwnEntryOr404(id, currentUser) {
const entry = await repo.findById(id);
if (!entry || entry.user_id !== currentUser.id) throw new HttpError(404, 'No encontrado');
return entry;
}
function stripSensitive(entry) {
const { password_enc, notes_enc, ...rest } = entry;
return rest;
}
function decryptEntry(entry) {
return {
...stripSensitive(entry),
password: entry.password_enc ? decrypt(entry.password_enc) : '',
notes: entry.notes_enc ? decrypt(entry.notes_enc) : '',
};
}
export async function listEntries(currentUser) {
const rows = await repo.findByUser(currentUser.id);
return rows; // sin password/notes -- la lista nunca trae datos sensibles
}
// "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita.
export async function getEntry(id, currentUser) {
const entry = await getOwnEntryOr404(id, currentUser);
await logAudit({ userId: currentUser.id, action: 'view', entityType: 'vault_entry', entityId: id });
return decryptEntry(entry);
}
export async function createEntry(payload, currentUser) {
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
const entry = await repo.create(currentUser.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,
});
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);
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
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,
});
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id });
return stripSensitive(updated);
}
export async function removeEntry(id, currentUser) {
await getOwnEntryOr404(id, currentUser);
await repo.remove(id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_entry', entityId: id });
}
// --- PIN ---
export async function getStatus(currentUser) {
const hash = await repo.getVaultPinHash(currentUser.id);
return { hasPinSet: !!hash };
}
export async function createPin(pin, currentUser) {
if (!PIN_RE.test(pin)) throw new HttpError(400, 'El PIN debe ser de 4 numeros');
const existing = await repo.getVaultPinHash(currentUser.id);
if (existing) throw new HttpError(400, 'Ya tenes un PIN creado -- usa "cambiar PIN" en su lugar');
const hash = await bcrypt.hash(pin, 12);
await repo.setVaultPinHash(currentUser.id, hash);
await logAudit({ userId: currentUser.id, action: 'create_pin', entityType: 'vault_pin', entityId: currentUser.id });
}
export async function unlock(pin, currentUser) {
if (!PIN_RE.test(pin)) throw new HttpError(400, 'PIN invalido');
const hash = await repo.getVaultPinHash(currentUser.id);
if (!hash) throw new HttpError(400, 'Todavia no creaste un PIN');
const ok = await bcrypt.compare(pin, hash);
if (!ok) throw new HttpError(403, 'PIN incorrecto');
const vaultToken = jwt.sign({ sub: currentUser.id, scope: 'vault' }, env.jwtSecret, { expiresIn: VAULT_TOKEN_EXPIRES });
await logAudit({ userId: currentUser.id, action: 'unlock', entityType: 'vault_pin', entityId: currentUser.id });
return { vaultToken };
}
export async function changePin(currentPin, newPin, currentUser) {
if (!PIN_RE.test(newPin)) throw new HttpError(400, 'El PIN nuevo debe ser de 4 numeros');
const hash = await repo.getVaultPinHash(currentUser.id);
if (!hash) throw new HttpError(400, 'Todavia no creaste un PIN');
const ok = await bcrypt.compare(currentPin, hash);
if (!ok) throw new HttpError(403, 'PIN actual incorrecto');
const newHash = await bcrypt.hash(newPin, 12);
await repo.setVaultPinHash(currentUser.id, newHash);
await logAudit({ userId: currentUser.id, action: 'change_pin', entityType: 'vault_pin', entityId: currentUser.id });
}
// Solo cambia el PIN -- nunca toca vault_entries. No es "recuperar datos", es resetear el candado.
export async function forgotPin(currentUser) {
const token = newToken();
await repo.setPinResetToken(currentUser.id, token);
const resetUrl = `${env.corsOrigin}/restablecer-pin?token=${token}`;
await sendMail({
to: currentUser.email,
subject: 'Restablecer PIN de Contraseñas — project.linkedcorp.net',
html: `<p>Hola ${currentUser.name},</p>
<p>Pediste restablecer el PIN de acceso a tu repositorio de Contraseñas. Esto solo cambia
el PIN -- tus contraseñas guardadas no se tocan ni se pierden.</p>
<p>Elegi un PIN nuevo en este enlace (vence en 1 hora):</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>Si no pediste esto, ignora el correo.</p>`,
});
}
export async function resetPin(token, newPin) {
if (!PIN_RE.test(newPin)) throw new HttpError(400, 'El PIN debe ser de 4 numeros');
const user = await repo.findByPinResetToken(token);
if (!user) throw new HttpError(400, 'El enlace para restablecer el PIN es invalido o expiro');
const hash = await bcrypt.hash(newPin, 12);
await repo.setVaultPinHash(user.id, hash);
await logAudit({ userId: user.id, action: 'reset_pin', entityType: 'vault_pin', entityId: user.id });
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "2.6.1", "version": "2.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+4
View File
@@ -20,6 +20,8 @@ import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx';
import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx'; import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
import NotasPage from '../pages/Notas/NotasPage.jsx'; import NotasPage from '../pages/Notas/NotasPage.jsx';
import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx';
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
export default function App() { export default function App() {
return ( return (
@@ -34,6 +36,7 @@ export default function App() {
<Route path="/verificar-correo" element={<VerifyEmailPage />} /> <Route path="/verificar-correo" element={<VerifyEmailPage />} />
<Route path="/olvide-clave" element={<ForgotPasswordPage />} /> <Route path="/olvide-clave" element={<ForgotPasswordPage />} />
<Route path="/restablecer-clave" element={<ResetPasswordPage />} /> <Route path="/restablecer-clave" element={<ResetPasswordPage />} />
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} /> <Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}> <Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
@@ -41,6 +44,7 @@ export default function App() {
<Route path="proyectos/:id" element={<ProyectoDetallePage />} /> <Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} /> <Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="notas" element={<NotasPage />} /> <Route path="notas" element={<NotasPage />} />
<Route path="contrasenas" element={<ContrasenasPage />} />
<Route path="organizacion" element={<OrganizacionPage />} /> <Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} /> <Route path="configuracion" element={<ConfiguracionPage />} />
</Route> </Route>
+2 -1
View File
@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote } from 'lucide-react'; import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx'; import { useUi } from '../context/ui-context.jsx';
import { useAuth } from '../context/auth-context.jsx'; import { useAuth } from '../context/auth-context.jsx';
import { useBranding } from '../context/branding-context.jsx'; import { useBranding } from '../context/branding-context.jsx';
@@ -13,6 +13,7 @@ const ITEMS = [
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' }, { to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' }, { to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' }, { to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
{ to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' },
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
]; ];
+54
View File
@@ -185,6 +185,33 @@ export const messages = {
nav_recurring_tasks: 'Tareas recurrentes', nav_recurring_tasks: 'Tareas recurrentes',
nav_notes: 'Notas', nav_notes: 'Notas',
nav_passwords: 'Contraseñas',
vault_pin_label: 'PIN (4 números)',
vault_pin_confirm_label: 'Confirmar PIN',
vault_pin_current_label: 'PIN actual',
vault_pin_invalid: 'El PIN debe ser de 4 números',
vault_pin_mismatch: 'Los PIN no coinciden',
vault_create_pin_title: 'Crear PIN de acceso',
vault_create_pin_hint: 'Este PIN protege el acceso a tus contraseñas guardadas. Vas a necesitarlo cada vez que entres a esta sección.',
vault_create_pin_submit: 'Crear PIN y entrar',
vault_enter_pin_title: 'Ingresa tu PIN',
vault_unlock: 'Desbloquear',
vault_forgot_pin: '¿Olvidaste tu PIN?',
vault_forgot_pin_sent: 'Te enviamos un correo para restablecer tu PIN. Tus contraseñas guardadas no se ven afectadas.',
vault_change_pin: 'Cambiar PIN',
vault_reset_pin_title: 'Restablecer PIN',
vault_reset_pin_submit: 'Guardar PIN nuevo',
vault_reset_pin_success: 'Tu PIN fue actualizado. Tus contraseñas guardadas siguen intactas.',
vault_new_entry: 'Nueva credencial',
vault_field_title: 'Nombre',
vault_field_url: 'Enlace',
vault_field_username: 'Usuario',
vault_field_password: 'Contraseña',
vault_field_notes: 'Notas',
vault_reveal: 'Revelar',
vault_copy: 'Copiar',
vault_copied: 'Copiado al portapapeles',
vault_generate: 'Generar',
notes_title: 'Notas', notes_title: 'Notas',
notes_placeholder: 'Escribe un apunte rápido...', notes_placeholder: 'Escribe un apunte rápido...',
notes_add: 'Agregar', notes_add: 'Agregar',
@@ -484,6 +511,33 @@ export const messages = {
nav_recurring_tasks: 'Recurring tasks', nav_recurring_tasks: 'Recurring tasks',
nav_notes: 'Notes', nav_notes: 'Notes',
nav_passwords: 'Passwords',
vault_pin_label: 'PIN (4 digits)',
vault_pin_confirm_label: 'Confirm PIN',
vault_pin_current_label: 'Current PIN',
vault_pin_invalid: 'The PIN must be 4 digits',
vault_pin_mismatch: 'The PINs don\'t match',
vault_create_pin_title: 'Create access PIN',
vault_create_pin_hint: 'This PIN protects access to your saved passwords. You\'ll need it every time you enter this section.',
vault_create_pin_submit: 'Create PIN and enter',
vault_enter_pin_title: 'Enter your PIN',
vault_unlock: 'Unlock',
vault_forgot_pin: 'Forgot your PIN?',
vault_forgot_pin_sent: 'We sent you an email to reset your PIN. Your saved passwords are not affected.',
vault_change_pin: 'Change PIN',
vault_reset_pin_title: 'Reset PIN',
vault_reset_pin_submit: 'Save new PIN',
vault_reset_pin_success: 'Your PIN was updated. Your saved passwords are still intact.',
vault_new_entry: 'New credential',
vault_field_title: 'Name',
vault_field_url: 'Link',
vault_field_username: 'Username',
vault_field_password: 'Password',
vault_field_notes: 'Notes',
vault_reveal: 'Reveal',
vault_copy: 'Copy',
vault_copied: 'Copied to clipboard',
vault_generate: 'Generate',
notes_title: 'Notes', notes_title: 'Notes',
notes_placeholder: 'Write a quick note...', notes_placeholder: 'Write a quick note...',
notes_add: 'Add', notes_add: 'Add',
@@ -0,0 +1,333 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAction } from '../../hooks/useAsync.js';
import {
getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin,
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';
const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '' };
function randomPassword(length = 20) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
const bytes = crypto.getRandomValues(new Uint32Array(length));
return Array.from(bytes, (b) => chars[b % chars.length]).join('');
}
export default function ContrasenasPage() {
const { t, pushToast, openDialog } = useUi();
const runAction = useAction();
const [locked, setLocked] = useState(true);
const [checking, setChecking] = useState(true);
const [hasPinSet, setHasPinSet] = useState(null);
const [entries, setEntries] = useState(null);
const [pin, setPin] = useState('');
const [pinConfirm, setPinConfirm] = useState('');
const [pinError, setPinError] = useState('');
const [busy, setBusy] = useState(false);
const [selected, setSelected] = useState(null); // entrada abierta en el popup
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [revealedPassword, setRevealedPassword] = useState(null);
const [passwordVisible, setPasswordVisible] = useState(false);
const [creating, setCreating] = useState(false);
const [changingPin, setChangingPin] = useState(false);
const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' });
const [pinChangeError, setPinChangeError] = useState('');
async function loadEntries() {
const data = await listVaultEntries();
setEntries(data);
setLocked(false);
}
async function checkAccess() {
setChecking(true);
try {
await loadEntries();
} catch {
setLocked(true);
try {
const status = await getVaultStatus();
setHasPinSet(status.hasPinSet);
} catch { /* ignora, se reintenta al enviar el pin */ }
} finally {
setChecking(false);
}
}
useEffect(() => { checkAccess(); }, []);
async function submitPin() {
setPinError('');
if (!/^\d{4}$/.test(pin)) { setPinError(t('vault_pin_invalid')); return; }
setBusy(true);
try {
if (!hasPinSet) {
if (pin !== pinConfirm) { setPinError(t('vault_pin_mismatch')); setBusy(false); return; }
await createVaultPin(pin);
}
await unlockVault(pin);
setPin(''); setPinConfirm('');
await loadEntries();
} catch (err) {
setPinError(err.message);
} finally {
setBusy(false);
}
}
async function handleForgotPin() {
await runAction(async () => {
await forgotVaultPin();
pushToast({ tone: 'success', message: t('vault_forgot_pin_sent') });
});
}
async function submitChangePin() {
setPinChangeError('');
if (!/^\d{4}$/.test(pinChangeForm.next)) { setPinChangeError(t('vault_pin_invalid')); return; }
if (pinChangeForm.next !== pinChangeForm.next2) { setPinChangeError(t('vault_pin_mismatch')); return; }
try {
await changeVaultPin(pinChangeForm.current, pinChangeForm.next);
setChangingPin(false);
setPinChangeForm({ current: '', next: '', next2: '' });
pushToast({ tone: 'success', message: t('common_saved') });
} catch (err) {
setPinChangeError(err.message);
}
}
function openEntry(entry) {
setSelected(entry);
setForm({ title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '' });
setRevealedPassword(null);
setPasswordVisible(false);
setEditing(false);
setCreating(false);
}
function openCreate() {
setSelected(null);
setForm(EMPTY_FORM);
setRevealedPassword(null);
setPasswordVisible(false);
setEditing(true);
setCreating(true);
}
function closePopup() {
setSelected(null);
setCreating(false);
setEditing(false);
}
async function reveal() {
await runAction(async () => {
const full = await getVaultEntry(selected.id);
setRevealedPassword(full.password);
setForm((f) => ({ ...f, notes: full.notes || '' }));
setPasswordVisible(true);
});
}
function startEdit() {
setForm((f) => ({ ...f, password: revealedPassword || '' }));
setEditing(true);
}
function cancelEdit() {
if (creating) { closePopup(); return; }
setEditing(false);
openEntry(selected);
}
async function save() {
if (!form.title.trim()) return;
await runAction(async () => {
if (creating) {
await createVaultEntry(form);
pushToast({ tone: 'success', message: t('common_saved') });
} else {
await updateVaultEntry(selected.id, form);
pushToast({ tone: 'success', message: t('common_saved') });
}
closePopup();
await loadEntries();
});
}
function confirmDelete(entry) {
openDialog({
tone: 'warning',
title: t('common_confirm_delete_title'),
message: t('common_confirm_delete_msg'),
actions: [
{ label: t('common_cancel'), variant: 'standard' },
{
label: t('common_delete'), variant: 'danger',
onClick: () => runAction(async () => {
await deleteVaultEntry(entry.id);
await loadEntries();
pushToast({ tone: 'success', message: t('common_saved') });
}),
},
],
});
}
function copyToClipboard(value) {
navigator.clipboard?.writeText(value);
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) {
return (
<div style={{ maxWidth: 360, margin: '60px auto' }} className="panel-card">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<ShieldAlert size={20} />
<h3 style={{ margin: 0 }}>{hasPinSet ? t('vault_enter_pin_title') : t('vault_create_pin_title')}</h3>
</div>
{!hasPinSet && <p className="text-muted" style={{ fontSize: '.8rem' }}>{t('vault_create_pin_hint')}</p>}
<Field label={t('vault_pin_label')}>
<input
className="input" type="password" inputMode="numeric" maxLength={4} autoFocus
value={pin} onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
/>
</Field>
{!hasPinSet && (
<Field label={t('vault_pin_confirm_label')}>
<input
className="input" type="password" inputMode="numeric" maxLength={4}
value={pinConfirm} onChange={(e) => setPinConfirm(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
/>
</Field>
)}
{pinError && <div className="login-error">{pinError}</div>}
<Button variant="accent" onClick={submitPin} disabled={busy} style={{ width: '100%', marginTop: 8 }}>
{hasPinSet ? t('vault_unlock') : t('vault_create_pin_submit')}
</Button>
{hasPinSet && (
<button type="button" className="link-button" style={{ marginTop: 10 }} onClick={handleForgotPin}>
{t('vault_forgot_pin')}
</button>
)}
</div>
);
}
return (
<div>
<div className="toolbar">
<h2 className="page-title mt-0" style={{ marginBottom: 0 }}>{t('nav_passwords')}</h2>
<div style={{ flex: 1 }} />
<Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button>
<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) },
]}
/>
<DetailPopup
open={creating || !!selected}
title={creating ? t('vault_new_entry') : (selected?.title || '')}
onClose={closePopup}
width={480}
height={560}
>
<Field label={t('vault_field_title')}>
<input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} />
</Field>
<Field label={t('vault_field_url')}>
<input className="input" value={form.url} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} />
</Field>
<Field label={t('vault_field_username')}>
<input className="input" value={form.username} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} />
</Field>
<Field label={t('vault_field_password')}>
{editing ? (
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
</div>
<Button variant="standard" onClick={() => setForm((f) => ({ ...f, password: randomPassword() }))}>{t('vault_generate')}</Button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input className="input" type={passwordVisible ? 'text' : 'password'} value={passwordVisible ? (revealedPassword || '') : '••••••••'} disabled readOnly style={{ flex: 1 }} />
{!revealedPassword ? (
<button className="icon-btn" title={t('vault_reveal')} onClick={reveal}><Eye size={16} /></button>
) : (
<>
<button className="icon-btn" onClick={() => setPasswordVisible((v) => !v)}>{passwordVisible ? <EyeOff size={16} /> : <Eye size={16} />}</button>
<button className="icon-btn" title={t('vault_copy')} onClick={() => copyToClipboard(revealedPassword)}><Copy size={16} /></button>
</>
)}
</div>
)}
</Field>
<Field label={t('vault_field_notes')}>
<textarea className="textarea" rows={3} disabled={!editing} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
{editing ? (
<>
<Button variant="accent" icon={Save} onClick={save} disabled={!form.title.trim()}>{t('common_save')}</Button>
<Button variant="standard" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
</>
) : (
<Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button>
)}
</div>
</DetailPopup>
<DetailPopup open={changingPin} title={t('vault_change_pin')} onClose={() => setChangingPin(false)} width={360} height={360}>
<Field label={t('vault_pin_current_label')}>
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.current} onChange={(e) => setPinChangeForm((f) => ({ ...f, current: e.target.value.replace(/\D/g, '') }))} />
</Field>
<Field label={t('vault_pin_label')}>
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.next} onChange={(e) => setPinChangeForm((f) => ({ ...f, next: e.target.value.replace(/\D/g, '') }))} />
</Field>
<Field label={t('vault_pin_confirm_label')}>
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.next2} onChange={(e) => setPinChangeForm((f) => ({ ...f, next2: e.target.value.replace(/\D/g, '') }))} />
</Field>
{pinChangeError && <div className="login-error">{pinChangeError}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" onClick={submitChangePin}>{t('common_save')}</Button>
<Button variant="standard" onClick={() => setChangingPin(false)}>{t('common_cancel')}</Button>
</div>
</DetailPopup>
</div>
);
}
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { KeyRound } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useBranding } from '../../context/branding-context.jsx';
import { resetVaultPin } from '../../services/vault.js';
import { Field } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import logoDark from '../../assets/logoLinkedCorpTxt.png';
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
export default function ResetVaultPinPage() {
const { t, theme } = useUi();
const branding = useBranding();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') || '';
const [pin, setPin] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
if (!/^\d{4}$/.test(pin)) { setError(t('vault_pin_invalid')); return; }
if (pin !== confirm) { setError(t('vault_pin_mismatch')); return; }
setLoading(true);
try {
await resetVaultPin(token, pin);
setSuccess(true);
setTimeout(() => navigate('/login', { replace: true }), 2000);
} catch (err) {
setError(err.message || t('common_error'));
} finally {
setLoading(false);
}
}
return (
<div className="login-shell">
<form className="login-card" onSubmit={handleSubmit}>
<div className="login-logo">
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
</div>
<div className="login-title">{t('vault_reset_pin_title')}</div>
{error && <div className="login-error">{error}</div>}
{success ? (
<div className="login-info">{t('vault_reset_pin_success')}</div>
) : (
<>
<Field label={t('vault_pin_label')}>
<input className="input" type="password" inputMode="numeric" maxLength={4} autoFocus
value={pin} onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))} required />
</Field>
<Field label={t('vault_pin_confirm_label')}>
<input className="input" type="password" inputMode="numeric" maxLength={4}
value={confirm} onChange={(e) => setConfirm(e.target.value.replace(/\D/g, ''))} required />
</Field>
<Button variant="accent" icon={KeyRound} type="submit" disabled={loading || !token} style={{ width: '100%', justifyContent: 'center' }}>
{t('vault_reset_pin_submit')}
</Button>
</>
)}
<div className="login-links">
<Link to="/login">{t('register_back_to_login')}</Link>
</div>
</form>
</div>
);
}
+6 -6
View File
@@ -9,8 +9,8 @@ function getToken() {
return localStorage.getItem('plc_token'); return localStorage.getItem('plc_token');
} }
async function request(method, path, { body, isForm } = {}) { async function request(method, path, { body, isForm, extraHeaders } = {}) {
const headers = {}; const headers = { ...extraHeaders };
const token = getToken(); const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
if (!isForm && body !== undefined) headers['Content-Type'] = 'application/json'; if (!isForm && body !== undefined) headers['Content-Type'] = 'application/json';
@@ -38,11 +38,11 @@ async function request(method, path, { body, isForm } = {}) {
} }
export const api = { export const api = {
get: (path) => request('GET', path), get: (path, extraHeaders) => request('GET', path, { extraHeaders }),
post: (path, body) => request('POST', path, { body }), post: (path, body, extraHeaders) => request('POST', path, { body, extraHeaders }),
put: (path, body) => request('PUT', path, { body }), put: (path, body, extraHeaders) => request('PUT', path, { body, extraHeaders }),
patch: (path, body) => request('PATCH', path, { body }), patch: (path, body) => request('PATCH', path, { body }),
delete: (path) => request('DELETE', path), delete: (path, extraHeaders) => request('DELETE', path, { extraHeaders }),
postForm: (path, formData) => request('POST', path, { body: formData, isForm: true }), postForm: (path, formData) => request('POST', path, { body: formData, isForm: true }),
putForm: (path, formData) => request('PUT', path, { body: formData, isForm: true }), putForm: (path, formData) => request('PUT', path, { body: formData, isForm: true }),
setToken: (token) => { setToken: (token) => {
+33
View File
@@ -0,0 +1,33 @@
import { api } from './api.js';
const VAULT_TOKEN_KEY = 'plc_vault_token';
export function getVaultToken() {
return sessionStorage.getItem(VAULT_TOKEN_KEY);
}
export function setVaultToken(token) {
if (token) sessionStorage.setItem(VAULT_TOKEN_KEY, token);
else sessionStorage.removeItem(VAULT_TOKEN_KEY);
}
function vaultHeaders() {
const token = getVaultToken();
return token ? { 'X-Vault-Token': token } : {};
}
export const getVaultStatus = () => api.get('/vault/status');
export const createVaultPin = (pin) => api.post('/vault/pin', { pin });
export const unlockVault = async (pin) => {
const { vaultToken } = await api.post('/vault/unlock', { pin });
setVaultToken(vaultToken);
return vaultToken;
};
export const changeVaultPin = (currentPin, newPin) => api.post('/vault/change-pin', { currentPin, newPin });
export const forgotVaultPin = () => api.post('/vault/forgot-pin');
export const resetVaultPin = (token, newPin) => api.post('/vault/reset-pin', { token, newPin });
export const listVaultEntries = () => api.get('/vault/entries', vaultHeaders());
export const getVaultEntry = (id) => api.get(`/vault/entries/${id}`, vaultHeaders());
export const createVaultEntry = (data) => api.post('/vault/entries', data, vaultHeaders());
export const updateVaultEntry = (id, data) => api.put(`/vault/entries/${id}`, data, vaultHeaders());
export const deleteVaultEntry = (id) => api.delete(`/vault/entries/${id}`, vaultHeaders());
+7
View File
@@ -348,6 +348,13 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.login-links { display: flex; justify-content: space-between; gap: 10px; margin-top: 14px; font-size: .76rem; flex-wrap: wrap; } .login-links { display: flex; justify-content: space-between; gap: 10px; margin-top: 14px; font-size: .76rem; flex-wrap: wrap; }
.login-links a { color: rgb(var(--muted)); text-decoration: none; } .login-links a { color: rgb(var(--muted)); text-decoration: none; }
.login-links a:hover { color: rgb(var(--accent)); text-decoration: underline; } .login-links a:hover { color: rgb(var(--accent)); text-decoration: underline; }
.link-button {
background: none; border: none; padding: 0; cursor: pointer;
color: rgb(var(--muted)); font-size: .76rem; text-decoration: none;
}
.link-button:hover { color: rgb(var(--accent)); text-decoration: underline; }
.text-link { color: rgb(var(--accent)); text-decoration: none; }
.text-link:hover { text-decoration: underline; }
/* --- 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