feat: version 2.14.0 -- compartir grupo completo y destruir con cascada

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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-14 17:42:39 -05:00
parent dad73b841d
commit 7139f1604e
12 changed files with 151 additions and 24 deletions
+6 -1
View File
@@ -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));
});
+26 -2
View File
@@ -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) {
+2 -1
View File
@@ -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);
+21 -2
View File
@@ -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) {