From 7139f1604ea95027f55e6a88299f11c64fbef521 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Wed, 14 Oct 2026 17:42:39 -0500 Subject: [PATCH] feat: version 2.14.0 -- compartir grupo completo y destruir con cascada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contraseñas: "Compartir grupo" aplica el mismo share de solo lectura a todas las credenciales de un grupo de una vez (el grupo sigue sin ser una unidad de permiso -- es una accion de conveniencia). El grupo y cada credencial se marcan con el icono de compartido. "Destruir grupo" ahora tiene dos opciones: solo desagrupar (como antes) o destruir tambien las N credenciales, con advertencia explicita del conteo. Fix de paso en ComboBox (afecta a toda la app): el panel se cerraba solo al abrirse si el trigger estaba cerca del borde de un contenedor con scroll, por un micro-scroll automatico del navegador al enfocar el boton. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 14 +++++ VERSION | 2 +- backend/package.json | 2 +- backend/src/modules/vault/controller.js | 7 ++- backend/src/modules/vault/repository.js | 28 ++++++++- backend/src/modules/vault/routes.js | 3 +- backend/src/modules/vault/service.js | 23 +++++++- frontend/package.json | 2 +- frontend/src/components/ComboBox.jsx | 10 +++- frontend/src/i18n/messages.js | 24 ++++++-- .../src/pages/Contrasenas/ContrasenasPage.jsx | 57 +++++++++++++++++-- frontend/src/services/vault.js | 3 +- 12 files changed, 151 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d7cc0..19e08a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog - project.linkedcorp.net +## [2.14.0] - 2026-10-14 +### Added +- Contraseñas: "Compartir grupo" en el menú de un grupo — comparte de una vez todas sus + credenciales (de solo lectura) con un usuario, y marca tanto cada credencial como la tarjeta + del grupo con el ícono de compartido (basta con que una sola esté compartida). +- Contraseñas: al destruir un grupo ahora hay dos opciones — "Destruir grupo" (como antes, solo + desagrupa) y "Destruir grupo y credenciales" (nueva, borra también todas sus credenciales, + con advertencia que muestra cuántas). +### Fixed +- ComboBox: el panel se cerraba solo apenas se abría si el trigger estaba cerca del borde de un + contenedor con scroll (ej. el popup de grupo) — un micro-scroll automático del navegador al + enfocar el botón disparaba el cierre por "scroll detectado". Corregido en todos los ComboBox + de la app, no solo en Contraseñas. + ## [2.13.2] - 2026-10-14 ### Changed - Contraseñas (solo este módulo): borrar una credencial o un grupo ahora dice "Destruir" en vez diff --git a/VERSION b/VERSION index 0e83a9a..edcfe40 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.13.2 +2.14.0 diff --git a/backend/package.json b/backend/package.json index 9c744c1..0f37918 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.13.2", + "version": "2.14.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/vault/controller.js b/backend/src/modules/vault/controller.js index c2e9463..55a4e12 100644 --- a/backend/src/modules/vault/controller.js +++ b/backend/src/modules/vault/controller.js @@ -63,10 +63,15 @@ export const putGroup = asyncHandler(async (req, res) => { }); export const deleteGroup = asyncHandler(async (req, res) => { - await service.removeGroup(Number(req.params.id), req.currentUser); + const cascade = req.query.cascade === 'true'; + await service.removeGroup(Number(req.params.id), req.currentUser, cascade); ok(res, { deleted: true }); }); +export const postGroupShare = asyncHandler(async (req, res) => { + ok(res, await service.shareGroup(Number(req.params.id), Number(req.body.userId), req.currentUser), 201); +}); + export const getEntryShares = asyncHandler(async (req, res) => { ok(res, await service.listEntryShares(Number(req.params.id), req.currentUser)); }); diff --git a/backend/src/modules/vault/repository.js b/backend/src/modules/vault/repository.js index 2271a58..a3e93f7 100644 --- a/backend/src/modules/vault/repository.js +++ b/backend/src/modules/vault/repository.js @@ -71,14 +71,19 @@ export async function remove(id) { // --- Grupos (carpetas de un solo nivel) --- +// shared_entries_count: cuantas entradas del grupo estan compartidas con AL MENOS un usuario -- +// alcanza con 1 para que la tarjeta del grupo muestre el icono de compartido (ver service.js). export async function findGroupsByUser(userId) { const [rows] = await pool.query( `SELECT g.id, g.user_id, g.name, g.icon_key, g.created_at, g.updated_at, - (SELECT COUNT(*) FROM vault_entries e WHERE e.group_id = g.id) AS entry_count + (SELECT COUNT(*) FROM vault_entries e WHERE e.group_id = g.id) AS entry_count, + (SELECT COUNT(DISTINCT e.id) FROM vault_entries e + JOIN vault_entry_shares s ON s.entry_id = e.id + WHERE e.group_id = g.id) AS shared_entries_count FROM vault_groups g WHERE g.user_id = ? ORDER BY g.name`, [userId] ); - return rows.map((r) => ({ ...r, entry_count: Number(r.entry_count) })); + return rows.map((r) => ({ ...r, entry_count: Number(r.entry_count), shared_entries_count: Number(r.shared_entries_count) })); } export async function findGroupById(id) { @@ -104,6 +109,13 @@ export async function removeGroup(id) { await pool.query('DELETE FROM vault_groups WHERE id = ?', [id]); } +// Borrado en cascada: elimina primero las entradas del grupo (irreversible, ver vault/service.js), +// despues el grupo mismo. Separado de removeGroup() porque ese sigue siendo el borrado "suave" +// (solo desagrupa) que ya existia. +export async function removeEntriesInGroup(groupId) { + await pool.query('DELETE FROM vault_entries WHERE group_id = ?', [groupId]); +} + // --- Compartir una entrada (solo lectura, ver vault/service.js) --- export async function listShares(entryId) { @@ -131,6 +143,18 @@ export async function removeShare(entryId, userId) { return listShares(entryId); } +// Compartir TODAS las entradas de un grupo de una vez (accion de conveniencia -- sigue siendo +// un share por entrada, el grupo nunca es una unidad de permiso: ver vault/service.js#shareGroup). +// Devuelve cuantas entradas tiene el grupo (para el mensaje de confirmacion en el frontend). +export async function shareGroupEntries(groupId, userId) { + await pool.query( + 'INSERT IGNORE INTO vault_entry_shares (entry_id, user_id) SELECT id, ? FROM vault_entries WHERE group_id = ?', + [userId, groupId] + ); + const [rows] = await pool.query('SELECT COUNT(*) AS total FROM vault_entries WHERE group_id = ?', [groupId]); + return Number(rows[0].total); +} + // --- PIN de acceso al modulo (en la tabla users, ver migracion 017) --- export async function getVaultPinHash(userId) { diff --git a/backend/src/modules/vault/routes.js b/backend/src/modules/vault/routes.js index 7b67e66..6b7a809 100644 --- a/backend/src/modules/vault/routes.js +++ b/backend/src/modules/vault/routes.js @@ -5,7 +5,7 @@ import { requireVaultUnlock } from './middleware.js'; import { getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin, getEntries, getEntry, postEntry, putEntry, deleteEntry, - getGroups, postGroup, putGroup, deleteGroup, + getGroups, postGroup, putGroup, deleteGroup, postGroupShare, getEntryShares, postEntryShare, deleteEntryShare, } from './controller.js'; @@ -28,6 +28,7 @@ router.get('/groups', requireVaultUnlock, getGroups); router.post('/groups', requireVaultUnlock, postGroup); router.put('/groups/:id', requireVaultUnlock, putGroup); router.delete('/groups/:id', requireVaultUnlock, deleteGroup); +router.post('/groups/:id/share', requireVaultUnlock, postGroupShare); router.get('/entries', requireVaultUnlock, getEntries); router.get('/entries/:id', requireVaultUnlock, getEntry); diff --git a/backend/src/modules/vault/service.js b/backend/src/modules/vault/service.js index 8605bb3..f3dceed 100644 --- a/backend/src/modules/vault/service.js +++ b/backend/src/modules/vault/service.js @@ -176,10 +176,14 @@ export async function updateGroup(id, payload, currentUser) { return group; } -export async function removeGroup(id, currentUser) { +// cascade=false (default): borrado "suave", las entradas quedan sin grupo (como siempre). +// cascade=true: ademas destruye TODAS las entradas del grupo -- irreversible, ver confirmacion +// fuerte en el frontend (ContrasenasPage.jsx#confirmDeleteGroup). +export async function removeGroup(id, currentUser, cascade = false) { await getOwnGroupOr404(id, currentUser); + if (cascade) await repo.removeEntriesInGroup(id); await repo.removeGroup(id); - await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_group', entityId: id }); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_group', entityId: id, detail: { cascade } }); } // --- Compartir una entrada (solo lectura) --- @@ -207,6 +211,21 @@ export async function unshareEntry(id, targetUserId, currentUser) { return shares; } +// Compartir TODAS las entradas de un grupo de una vez (conveniencia desde el menu del grupo) -- +// el grupo sigue sin ser una unidad de permiso: esto solo aplica el mismo share de siempre a +// cada entrada que tiene hoy ese group_id, una por una. Entradas que se agreguen al grupo +// despues NO heredan el share retroactivamente. +export async function shareGroup(groupId, targetUserId, currentUser) { + await getOwnGroupOr404(groupId, currentUser); + if (!targetUserId) throw new HttpError(400, 'Indica un usuario'); + if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir contigo mismo'); + const targetUser = await usersRepo.findById(targetUserId); + if (!targetUser) throw new HttpError(404, 'Usuario no encontrado'); + const sharedCount = await repo.shareGroupEntries(groupId, targetUserId); + await logAudit({ userId: currentUser.id, action: 'share', entityType: 'vault_group', entityId: groupId, detail: { userId: targetUserId, entriesCount: sharedCount } }); + return { sharedCount }; +} + // --- PIN --- export async function getStatus(currentUser) { diff --git a/frontend/package.json b/frontend/package.json index 322e707..a39a4e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.13.2", + "version": "2.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ComboBox.jsx b/frontend/src/components/ComboBox.jsx index 7cb1315..09c7c13 100644 --- a/frontend/src/components/ComboBox.jsx +++ b/frontend/src/components/ComboBox.jsx @@ -54,9 +54,15 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr // capture:true para detectar scroll en CUALQUIER ancestro (los eventos de scroll no burbujean). function onScroll() { setOpen(false); } document.addEventListener('mousedown', onDocMouseDown); - window.addEventListener('scroll', onScroll, true); - window.addEventListener('resize', onScroll); + // El listener de scroll se activa un frame despues de abrir -- si el trigger esta cerca del + // borde de un contenedor con scroll (ej. el body de un DetailPopup), el navegador hace un + // micro-scroll automatico al enfocarlo, lo que cerraba el panel apenas se abria. + const raf = requestAnimationFrame(() => { + window.addEventListener('scroll', onScroll, true); + window.addEventListener('resize', onScroll); + }); return () => { + cancelAnimationFrame(raf); document.removeEventListener('mousedown', onDocMouseDown); window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onScroll); diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index fcfd27a..4c43476 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -227,17 +227,23 @@ export const messages = { vault_group_none: 'Sin grupo', vault_group_back: 'Volver', vault_group_entries_count: 'credencial(es)', - vault_group_delete_confirm_msg: 'Se elimina el grupo. Las credenciales que contiene no se borran, quedan sin grupo.', vault_field_group: 'Grupo', vault_share_title: 'Compartir', vault_share_add: 'Compartir con', vault_share_placeholder: 'Elegir usuario', vault_share_empty: 'No compartida con nadie.', + vault_share_group_action: 'Compartir grupo', + vault_share_group_hint_prefix: 'Comparte las ', + vault_share_group_hint_suffix: ' credenciales de este grupo, de solo lectura.', vault_shared_by: 'Compartida por', vault_destroy: 'Destruir', + vault_destroy_group_action: 'Destruir grupo', + vault_destroy_group_cascade_action: 'Destruir grupo y credenciales', vault_confirm_destroy_title: 'Destrucción irreversible', - vault_confirm_destroy_entry_msg: 'Vas a DESTRUIR esta credencial de forma permanente. No hay papelera ni copia de respaldo -- una vez confirmado, no hay forma de recuperarla.', - vault_confirm_destroy_group_msg: 'Vas a DESTRUIR este grupo de forma permanente. No hay papelera ni copia de respaldo -- una vez confirmado, no hay forma de recuperarlo. Las credenciales que contiene no se destruyen, quedan sin grupo.', + vault_confirm_destroy_entry_msg: 'Esta credencial se destruye para siempre. No se puede recuperar.', + vault_confirm_destroy_group_msg: 'Este grupo se destruye para siempre. Sus credenciales quedan sin grupo, no se borran.', + vault_confirm_destroy_group_cascade_prefix: 'Se destruyen el grupo y sus ', + vault_confirm_destroy_group_cascade_suffix: ' credenciales, para siempre. No se puede recuperar nada.', notes_title: 'Notas', notes_placeholder: 'Escribe un apunte rápido...', notes_empty: 'No tienes notas todavía.', @@ -586,17 +592,23 @@ export const messages = { vault_group_none: 'No group', vault_group_back: 'Back', vault_group_entries_count: 'credential(s)', - vault_group_delete_confirm_msg: 'The group is removed. Its credentials are not deleted, they stay ungrouped.', vault_field_group: 'Group', vault_share_title: 'Share', vault_share_add: 'Share with', vault_share_placeholder: 'Choose a user', vault_share_empty: 'Not shared with anyone.', + vault_share_group_action: 'Share group', + vault_share_group_hint_prefix: 'Shares the ', + vault_share_group_hint_suffix: ' credentials in this group, read-only.', vault_shared_by: 'Shared by', vault_destroy: 'Destroy', + vault_destroy_group_action: 'Destroy group', + vault_destroy_group_cascade_action: 'Destroy group and credentials', vault_confirm_destroy_title: 'Irreversible destruction', - vault_confirm_destroy_entry_msg: 'You are about to permanently DESTROY this credential. There is no trash bin or backup copy -- once confirmed, there is no way to recover it.', - vault_confirm_destroy_group_msg: 'You are about to permanently DESTROY this group. There is no trash bin or backup copy -- once confirmed, there is no way to recover it. Its credentials are not destroyed, they stay ungrouped.', + vault_confirm_destroy_entry_msg: 'This credential is destroyed forever. It cannot be recovered.', + vault_confirm_destroy_group_msg: 'This group is destroyed forever. Its credentials stay ungrouped, they are not deleted.', + vault_confirm_destroy_group_cascade_prefix: 'The group and its ', + vault_confirm_destroy_group_cascade_suffix: ' credentials are destroyed forever. Nothing can be recovered.', notes_title: 'Notes', notes_placeholder: 'Write a quick note...', notes_empty: 'You don\'t have any notes yet.', diff --git a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx index 0e5c69f..423b9e2 100644 --- a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx +++ b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx @@ -11,7 +11,7 @@ import { useDebouncedValue } from '../../hooks/useDebouncedValue.js'; import { getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin, setVaultToken, listVaultEntries, getVaultEntry, createVaultEntry, updateVaultEntry, deleteVaultEntry, - listVaultGroups, createVaultGroup, updateVaultGroup, deleteVaultGroup, + listVaultGroups, createVaultGroup, updateVaultGroup, deleteVaultGroup, shareVaultGroup, listEntryShares, addEntryShare, removeEntryShare, } from '../../services/vault.js'; import { listUsers } from '../../services/users.js'; @@ -102,6 +102,7 @@ export default function ContrasenasPage() { const [groupPopupOpen, setGroupPopupOpen] = useState(false); const [editingGroup, setEditingGroup] = useState(null); const [groupForm, setGroupForm] = useState(EMPTY_GROUP_FORM); + const [groupSharingWith, setGroupSharingWith] = useState(''); const [changingPin, setChangingPin] = useState(false); const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' }); @@ -319,12 +320,24 @@ export default function ContrasenasPage() { function openEditGroup(group) { setEditingGroup(group); setGroupForm({ name: group.name, iconKey: group.icon_key || '' }); + setGroupSharingWith(''); setGroupPopupOpen(true); } function closeGroupPopup() { setGroupPopupOpen(false); setEditingGroup(null); + setGroupSharingWith(''); + } + + async function shareGroup() { + if (!groupSharingWith || !editingGroup) return; + await runAction(async () => { + await shareVaultGroup(editingGroup.id, Number(groupSharingWith)); + setGroupSharingWith(''); + pushToast({ tone: 'success', message: t('common_saved') }); + await Promise.all([loadGroups(), loadEntries()]); + }); } async function saveGroup() { @@ -341,17 +354,20 @@ export default function ContrasenasPage() { }); } - function confirmDeleteGroup(group) { + function confirmDeleteGroup(group, cascade) { + const message = cascade + ? `${t('vault_confirm_destroy_group_cascade_prefix')}${group.entry_count}${t('vault_confirm_destroy_group_cascade_suffix')}` + : t('vault_confirm_destroy_group_msg'); openDialog({ tone: 'error', title: t('vault_confirm_destroy_title'), - message: t('vault_confirm_destroy_group_msg'), + message, actions: [ { label: t('common_cancel'), variant: 'standard' }, { label: t('vault_destroy'), variant: 'danger', onClick: () => runAction(async () => { - await deleteVaultGroup(group.id); + await deleteVaultGroup(group.id, cascade); if (activeGroupId === group.id) setActiveGroupId(null); await Promise.all([loadGroups(), loadEntries()]); pushToast({ tone: 'success', message: t('common_saved') }); @@ -446,9 +462,16 @@ export default function ContrasenasPage() { )}> openEditGroup(group)}>{t('common_edit')} - confirmDeleteGroup(group)}>{t('vault_destroy')} + openEditGroup(group)}>{t('vault_share_group_action')} + confirmDeleteGroup(group, false)}>{t('vault_destroy_group_action')} + confirmDeleteGroup(group, true)}>{t('vault_destroy_group_cascade_action')} + {group.shared_entries_count > 0 && ( + + + + )}
{group.name}
{group.entry_count} {t('vault_group_entries_count')}
@@ -655,7 +678,7 @@ export default function ContrasenasPage() { title={editingGroup ? editingGroup.name : t('vault_group_new')} onClose={closeGroupPopup} width={380} - height={300} + height={editingGroup ? 480 : 300} > setGroupForm((f) => ({ ...f, name: e.target.value }))} /> @@ -686,6 +709,28 @@ export default function ContrasenasPage() { + + {editingGroup && ( +
+

{t('vault_share_group_action')}

+
+ {t('vault_share_group_hint_prefix')}{editingGroup.entry_count}{t('vault_share_group_hint_suffix')} +
+ +
+
+ ({ value: u.id, label: u.name }))} + value={groupSharingWith} + onChange={setGroupSharingWith} + placeholder={t('vault_share_placeholder')} + /> +
+ +
+
+
+ )} setChangingPin(false)} width={360} height={360}> diff --git a/frontend/src/services/vault.js b/frontend/src/services/vault.js index f95a786..5b84291 100644 --- a/frontend/src/services/vault.js +++ b/frontend/src/services/vault.js @@ -35,7 +35,8 @@ export const deleteVaultEntry = (id) => api.delete(`/vault/entries/${id}`, vault export const listVaultGroups = () => api.get('/vault/groups', vaultHeaders()); export const createVaultGroup = (data) => api.post('/vault/groups', data, vaultHeaders()); export const updateVaultGroup = (id, data) => api.put(`/vault/groups/${id}`, data, vaultHeaders()); -export const deleteVaultGroup = (id) => api.delete(`/vault/groups/${id}`, vaultHeaders()); +export const deleteVaultGroup = (id, cascade) => api.delete(`/vault/groups/${id}${cascade ? '?cascade=true' : ''}`, vaultHeaders()); +export const shareVaultGroup = (id, userId) => api.post(`/vault/groups/${id}/share`, { userId }, vaultHeaders()); export const listEntryShares = (id) => api.get(`/vault/entries/${id}/shares`, vaultHeaders()); export const addEntryShare = (id, userId) => api.post(`/vault/entries/${id}/shares`, { userId }, vaultHeaders());