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
+8 -2
View File
@@ -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);
+18 -6
View File
@@ -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.',
@@ -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() {
<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={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>
</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} />
<div className="vault-card-title">{group.name}</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')}
onClose={closeGroupPopup}
width={380}
height={300}
height={editingGroup ? 480 : 300}
>
<Field label={t('vault_group_name')}>
<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="standard" icon={X} onClick={closeGroupPopup}>{t('common_cancel')}</Button>
</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 open={changingPin} title={t('vault_change_pin')} onClose={() => setChangingPin(false)} width={360} height={360}>
+2 -1
View File
@@ -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());