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() {
)}>