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:
@@ -1,5 +1,19 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.13.2] - 2026-10-14
|
||||||
### Changed
|
### Changed
|
||||||
- Contraseñas (solo este módulo): borrar una credencial o un grupo ahora dice "Destruir" en vez
|
- Contraseñas (solo este módulo): borrar una credencial o un grupo ahora dice "Destruir" en vez
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.13.2",
|
"version": "2.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -63,10 +63,15 @@ export const putGroup = asyncHandler(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const deleteGroup = 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 });
|
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) => {
|
export const getEntryShares = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.listEntryShares(Number(req.params.id), req.currentUser));
|
ok(res, await service.listEntryShares(Number(req.params.id), req.currentUser));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,14 +71,19 @@ export async function remove(id) {
|
|||||||
|
|
||||||
// --- Grupos (carpetas de un solo nivel) ---
|
// --- 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) {
|
export async function findGroupsByUser(userId) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT g.id, g.user_id, g.name, g.icon_key, g.created_at, g.updated_at,
|
`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`,
|
FROM vault_groups g WHERE g.user_id = ? ORDER BY g.name`,
|
||||||
[userId]
|
[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) {
|
export async function findGroupById(id) {
|
||||||
@@ -104,6 +109,13 @@ export async function removeGroup(id) {
|
|||||||
await pool.query('DELETE FROM vault_groups WHERE id = ?', [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) ---
|
// --- Compartir una entrada (solo lectura, ver vault/service.js) ---
|
||||||
|
|
||||||
export async function listShares(entryId) {
|
export async function listShares(entryId) {
|
||||||
@@ -131,6 +143,18 @@ export async function removeShare(entryId, userId) {
|
|||||||
return listShares(entryId);
|
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) ---
|
// --- PIN de acceso al modulo (en la tabla users, ver migracion 017) ---
|
||||||
|
|
||||||
export async function getVaultPinHash(userId) {
|
export async function getVaultPinHash(userId) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { requireVaultUnlock } from './middleware.js';
|
|||||||
import {
|
import {
|
||||||
getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin,
|
getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin,
|
||||||
getEntries, getEntry, postEntry, putEntry, deleteEntry,
|
getEntries, getEntry, postEntry, putEntry, deleteEntry,
|
||||||
getGroups, postGroup, putGroup, deleteGroup,
|
getGroups, postGroup, putGroup, deleteGroup, postGroupShare,
|
||||||
getEntryShares, postEntryShare, deleteEntryShare,
|
getEntryShares, postEntryShare, deleteEntryShare,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ router.get('/groups', requireVaultUnlock, getGroups);
|
|||||||
router.post('/groups', requireVaultUnlock, postGroup);
|
router.post('/groups', requireVaultUnlock, postGroup);
|
||||||
router.put('/groups/:id', requireVaultUnlock, putGroup);
|
router.put('/groups/:id', requireVaultUnlock, putGroup);
|
||||||
router.delete('/groups/:id', requireVaultUnlock, deleteGroup);
|
router.delete('/groups/:id', requireVaultUnlock, deleteGroup);
|
||||||
|
router.post('/groups/:id/share', requireVaultUnlock, postGroupShare);
|
||||||
|
|
||||||
router.get('/entries', requireVaultUnlock, getEntries);
|
router.get('/entries', requireVaultUnlock, getEntries);
|
||||||
router.get('/entries/:id', requireVaultUnlock, getEntry);
|
router.get('/entries/:id', requireVaultUnlock, getEntry);
|
||||||
|
|||||||
@@ -176,10 +176,14 @@ export async function updateGroup(id, payload, currentUser) {
|
|||||||
return group;
|
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);
|
await getOwnGroupOr404(id, currentUser);
|
||||||
|
if (cascade) await repo.removeEntriesInGroup(id);
|
||||||
await repo.removeGroup(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) ---
|
// --- Compartir una entrada (solo lectura) ---
|
||||||
@@ -207,6 +211,21 @@ export async function unshareEntry(id, targetUserId, currentUser) {
|
|||||||
return shares;
|
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 ---
|
// --- PIN ---
|
||||||
|
|
||||||
export async function getStatus(currentUser) {
|
export async function getStatus(currentUser) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.13.2",
|
"version": "2.14.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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).
|
// capture:true para detectar scroll en CUALQUIER ancestro (los eventos de scroll no burbujean).
|
||||||
function onScroll() { setOpen(false); }
|
function onScroll() { setOpen(false); }
|
||||||
document.addEventListener('mousedown', onDocMouseDown);
|
document.addEventListener('mousedown', onDocMouseDown);
|
||||||
|
// 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('scroll', onScroll, true);
|
||||||
window.addEventListener('resize', onScroll);
|
window.addEventListener('resize', onScroll);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
document.removeEventListener('mousedown', onDocMouseDown);
|
document.removeEventListener('mousedown', onDocMouseDown);
|
||||||
window.removeEventListener('scroll', onScroll, true);
|
window.removeEventListener('scroll', onScroll, true);
|
||||||
window.removeEventListener('resize', onScroll);
|
window.removeEventListener('resize', onScroll);
|
||||||
|
|||||||
@@ -227,17 +227,23 @@ export const messages = {
|
|||||||
vault_group_none: 'Sin grupo',
|
vault_group_none: 'Sin grupo',
|
||||||
vault_group_back: 'Volver',
|
vault_group_back: 'Volver',
|
||||||
vault_group_entries_count: 'credencial(es)',
|
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_field_group: 'Grupo',
|
||||||
vault_share_title: 'Compartir',
|
vault_share_title: 'Compartir',
|
||||||
vault_share_add: 'Compartir con',
|
vault_share_add: 'Compartir con',
|
||||||
vault_share_placeholder: 'Elegir usuario',
|
vault_share_placeholder: 'Elegir usuario',
|
||||||
vault_share_empty: 'No compartida con nadie.',
|
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_shared_by: 'Compartida por',
|
||||||
vault_destroy: 'Destruir',
|
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_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_entry_msg: 'Esta credencial se destruye para siempre. No se puede recuperar.',
|
||||||
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_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_title: 'Notas',
|
||||||
notes_placeholder: 'Escribe un apunte rápido...',
|
notes_placeholder: 'Escribe un apunte rápido...',
|
||||||
notes_empty: 'No tienes notas todavía.',
|
notes_empty: 'No tienes notas todavía.',
|
||||||
@@ -586,17 +592,23 @@ export const messages = {
|
|||||||
vault_group_none: 'No group',
|
vault_group_none: 'No group',
|
||||||
vault_group_back: 'Back',
|
vault_group_back: 'Back',
|
||||||
vault_group_entries_count: 'credential(s)',
|
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_field_group: 'Group',
|
||||||
vault_share_title: 'Share',
|
vault_share_title: 'Share',
|
||||||
vault_share_add: 'Share with',
|
vault_share_add: 'Share with',
|
||||||
vault_share_placeholder: 'Choose a user',
|
vault_share_placeholder: 'Choose a user',
|
||||||
vault_share_empty: 'Not shared with anyone.',
|
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_shared_by: 'Shared by',
|
||||||
vault_destroy: 'Destroy',
|
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_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_entry_msg: 'This credential is destroyed forever. It cannot be recovered.',
|
||||||
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_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_title: 'Notes',
|
||||||
notes_placeholder: 'Write a quick note...',
|
notes_placeholder: 'Write a quick note...',
|
||||||
notes_empty: 'You don\'t have any notes yet.',
|
notes_empty: 'You don\'t have any notes yet.',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useDebouncedValue } from '../../hooks/useDebouncedValue.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,
|
||||||
listVaultGroups, createVaultGroup, updateVaultGroup, deleteVaultGroup,
|
listVaultGroups, createVaultGroup, updateVaultGroup, deleteVaultGroup, shareVaultGroup,
|
||||||
listEntryShares, addEntryShare, removeEntryShare,
|
listEntryShares, addEntryShare, removeEntryShare,
|
||||||
} from '../../services/vault.js';
|
} from '../../services/vault.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
@@ -102,6 +102,7 @@ export default function ContrasenasPage() {
|
|||||||
const [groupPopupOpen, setGroupPopupOpen] = useState(false);
|
const [groupPopupOpen, setGroupPopupOpen] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState(null);
|
const [editingGroup, setEditingGroup] = useState(null);
|
||||||
const [groupForm, setGroupForm] = useState(EMPTY_GROUP_FORM);
|
const [groupForm, setGroupForm] = useState(EMPTY_GROUP_FORM);
|
||||||
|
const [groupSharingWith, setGroupSharingWith] = useState('');
|
||||||
|
|
||||||
const [changingPin, setChangingPin] = useState(false);
|
const [changingPin, setChangingPin] = useState(false);
|
||||||
const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' });
|
const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' });
|
||||||
@@ -319,12 +320,24 @@ export default function ContrasenasPage() {
|
|||||||
function openEditGroup(group) {
|
function openEditGroup(group) {
|
||||||
setEditingGroup(group);
|
setEditingGroup(group);
|
||||||
setGroupForm({ name: group.name, iconKey: group.icon_key || '' });
|
setGroupForm({ name: group.name, iconKey: group.icon_key || '' });
|
||||||
|
setGroupSharingWith('');
|
||||||
setGroupPopupOpen(true);
|
setGroupPopupOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeGroupPopup() {
|
function closeGroupPopup() {
|
||||||
setGroupPopupOpen(false);
|
setGroupPopupOpen(false);
|
||||||
setEditingGroup(null);
|
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() {
|
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({
|
openDialog({
|
||||||
tone: 'error',
|
tone: 'error',
|
||||||
title: t('vault_confirm_destroy_title'),
|
title: t('vault_confirm_destroy_title'),
|
||||||
message: t('vault_confirm_destroy_group_msg'),
|
message,
|
||||||
actions: [
|
actions: [
|
||||||
{ label: t('common_cancel'), variant: 'standard' },
|
{ label: t('common_cancel'), variant: 'standard' },
|
||||||
{
|
{
|
||||||
label: t('vault_destroy'), variant: 'danger',
|
label: t('vault_destroy'), variant: 'danger',
|
||||||
onClick: () => runAction(async () => {
|
onClick: () => runAction(async () => {
|
||||||
await deleteVaultGroup(group.id);
|
await deleteVaultGroup(group.id, cascade);
|
||||||
if (activeGroupId === group.id) setActiveGroupId(null);
|
if (activeGroupId === group.id) setActiveGroupId(null);
|
||||||
await Promise.all([loadGroups(), loadEntries()]);
|
await Promise.all([loadGroups(), loadEntries()]);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
@@ -446,9 +462,16 @@ export default function ContrasenasPage() {
|
|||||||
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical size={16} /></button>
|
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical size={16} /></button>
|
||||||
)}>
|
)}>
|
||||||
<FlyoutItem icon={Pencil} onClick={() => openEditGroup(group)}>{t('common_edit')}</FlyoutItem>
|
<FlyoutItem icon={Pencil} onClick={() => openEditGroup(group)}>{t('common_edit')}</FlyoutItem>
|
||||||
<FlyoutItem icon={Trash2} danger onClick={() => confirmDeleteGroup(group)}>{t('vault_destroy')}</FlyoutItem>
|
<FlyoutItem icon={Share2} onClick={() => openEditGroup(group)}>{t('vault_share_group_action')}</FlyoutItem>
|
||||||
|
<FlyoutItem icon={Trash2} danger onClick={() => confirmDeleteGroup(group, false)}>{t('vault_destroy_group_action')}</FlyoutItem>
|
||||||
|
<FlyoutItem icon={Trash2} danger onClick={() => confirmDeleteGroup(group, true)}>{t('vault_destroy_group_cascade_action')}</FlyoutItem>
|
||||||
</Flyout>
|
</Flyout>
|
||||||
</div>
|
</div>
|
||||||
|
{group.shared_entries_count > 0 && (
|
||||||
|
<span className="vault-card-shared-badge" title={t('vault_share_group_action')}>
|
||||||
|
<Share2 size={13} strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<GroupIcon iconKey={group.icon_key} />
|
<GroupIcon iconKey={group.icon_key} />
|
||||||
<div className="vault-card-title">{group.name}</div>
|
<div className="vault-card-title">{group.name}</div>
|
||||||
<div className="vault-card-url">{group.entry_count} {t('vault_group_entries_count')}</div>
|
<div className="vault-card-url">{group.entry_count} {t('vault_group_entries_count')}</div>
|
||||||
@@ -655,7 +678,7 @@ export default function ContrasenasPage() {
|
|||||||
title={editingGroup ? editingGroup.name : t('vault_group_new')}
|
title={editingGroup ? editingGroup.name : t('vault_group_new')}
|
||||||
onClose={closeGroupPopup}
|
onClose={closeGroupPopup}
|
||||||
width={380}
|
width={380}
|
||||||
height={300}
|
height={editingGroup ? 480 : 300}
|
||||||
>
|
>
|
||||||
<Field label={t('vault_group_name')}>
|
<Field label={t('vault_group_name')}>
|
||||||
<input className="input" autoFocus value={groupForm.name} onChange={(e) => setGroupForm((f) => ({ ...f, name: e.target.value }))} />
|
<input className="input" autoFocus value={groupForm.name} onChange={(e) => setGroupForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
@@ -686,6 +709,28 @@ export default function ContrasenasPage() {
|
|||||||
<Button variant="accent" icon={Save} onClick={saveGroup} disabled={!groupForm.name.trim()}>{t('common_save')}</Button>
|
<Button variant="accent" icon={Save} onClick={saveGroup} disabled={!groupForm.name.trim()}>{t('common_save')}</Button>
|
||||||
<Button variant="standard" icon={X} onClick={closeGroupPopup}>{t('common_cancel')}</Button>
|
<Button variant="standard" icon={X} onClick={closeGroupPopup}>{t('common_cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editingGroup && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<h4 style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Share2 size={15} /> {t('vault_share_group_action')}</h4>
|
||||||
|
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 8 }}>
|
||||||
|
{t('vault_share_group_hint_prefix')}{editingGroup.entry_count}{t('vault_share_group_hint_suffix')}
|
||||||
|
</div>
|
||||||
|
<Field label={t('vault_share_add')}>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<ComboBox
|
||||||
|
options={(users || []).map((u) => ({ value: u.id, label: u.name }))}
|
||||||
|
value={groupSharingWith}
|
||||||
|
onChange={setGroupSharingWith}
|
||||||
|
placeholder={t('vault_share_placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="standard" icon={UserPlus} onClick={shareGroup} disabled={!groupSharingWith}>{t('vault_share_add')}</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|
||||||
<DetailPopup open={changingPin} title={t('vault_change_pin')} onClose={() => setChangingPin(false)} width={360} height={360}>
|
<DetailPopup open={changingPin} title={t('vault_change_pin')} onClose={() => setChangingPin(false)} width={360} height={360}>
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export const deleteVaultEntry = (id) => api.delete(`/vault/entries/${id}`, vault
|
|||||||
export const listVaultGroups = () => api.get('/vault/groups', vaultHeaders());
|
export const listVaultGroups = () => api.get('/vault/groups', vaultHeaders());
|
||||||
export const createVaultGroup = (data) => api.post('/vault/groups', data, 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 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 listEntryShares = (id) => api.get(`/vault/entries/${id}/shares`, vaultHeaders());
|
||||||
export const addEntryShare = (id, userId) => api.post(`/vault/entries/${id}/shares`, { userId }, vaultHeaders());
|
export const addEntryShare = (id, userId) => api.post(`/vault/entries/${id}/shares`, { userId }, vaultHeaders());
|
||||||
|
|||||||
Reference in New Issue
Block a user