feat: version 2.13.0 -- grupos y compartir en Contraseñas

Grupos de un solo nivel (tarjetas, clic entra a ver sus credenciales) y compartir
una credencial individual de solo lectura con otro usuario registrado (puede
revelar/copiar la contraseña real, nunca editar/borrar/compartir/mover de grupo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-14 16:14:36 -05:00
parent 971de05773
commit 3da652c136
13 changed files with 570 additions and 43 deletions
+84 -3
View File
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import * as repo from './repository.js';
import * as usersRepo from '../users/repository.js';
import { env } from '../../config/env.js';
import { encrypt, decrypt } from '../../utils/crypto.js';
import { HttpError } from '../../utils/http.js';
@@ -47,6 +48,22 @@ async function getOwnEntryOr404(id, currentUser) {
return entry;
}
// Dueño o receptor de un share (solo lectura: nunca para editar/borrar/compartir/mover de
// grupo, eso sigue siendo getOwnEntryOr404). 404 tambien si no esta compartida -- nunca 403.
async function getReadableEntryOr404(id, currentUser) {
const entry = await repo.findById(id);
if (!entry) throw new HttpError(404, 'No encontrado');
if (entry.user_id === currentUser.id) return entry;
if (await repo.isSharedWith(id, currentUser.id)) return entry;
throw new HttpError(404, 'No encontrado');
}
async function getOwnGroupOr404(id, currentUser) {
const group = await repo.findGroupById(id);
if (!group || group.user_id !== currentUser.id) throw new HttpError(404, 'Grupo no encontrado');
return group;
}
function stripSensitive(entry) {
const { password_enc, notes_enc, ...rest } = entry;
return rest;
@@ -66,7 +83,7 @@ function decryptEntry(entry) {
// No se audita como "view": nunca se le muestra el texto de la nota al usuario, solo si matchea.
export async function listEntries(currentUser, query) {
const q = query?.trim().toLowerCase();
if (!q) return repo.findByUser(currentUser.id);
if (!q) return repo.findVisibleToUser(currentUser.id);
const rows = await repo.findByUserForSearch(currentUser.id);
return rows
@@ -82,15 +99,24 @@ export async function listEntries(currentUser, query) {
.map(stripSensitive);
}
// "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita.
// "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita. Dueño o
// receptor de un share pueden revelar (acordado con el usuario: compartir da acceso real a la
// credencial, no solo a los metadatos) -- nunca a quien no tiene ninguna de las dos relaciones.
export async function getEntry(id, currentUser) {
const entry = await getOwnEntryOr404(id, currentUser);
const entry = await getReadableEntryOr404(id, currentUser);
await logAudit({ userId: currentUser.id, action: 'view', entityType: 'vault_entry', entityId: id });
return decryptEntry(entry);
}
async function resolveGroupId(groupId, currentUser) {
if (!groupId) return null;
await getOwnGroupOr404(groupId, currentUser);
return groupId;
}
export async function createEntry(payload, currentUser) {
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
const groupId = await resolveGroupId(payload.groupId, currentUser);
const entry = await repo.create(currentUser.id, {
title: payload.title.trim(),
url: payload.url?.trim(),
@@ -98,6 +124,7 @@ export async function createEntry(payload, currentUser) {
passwordEnc: payload.password ? encrypt(payload.password) : null,
notesEnc: payload.notes ? encrypt(payload.notes) : null,
iconKey: sanitizeIconKey(payload.iconKey),
groupId,
});
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id });
return stripSensitive(entry);
@@ -106,6 +133,7 @@ export async function createEntry(payload, currentUser) {
export async function updateEntry(id, payload, currentUser) {
const entry = await getOwnEntryOr404(id, currentUser);
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
const groupId = await resolveGroupId(payload.groupId, currentUser);
// Si no viene password/notes nuevo (ej. se edito solo el nombre sin pasar por "Revelar"
// primero), se conserva lo que ya estaba guardado -- nunca se borra por venir vacio.
const updated = await repo.update(id, {
@@ -115,6 +143,7 @@ export async function updateEntry(id, payload, currentUser) {
passwordEnc: payload.password ? encrypt(payload.password) : entry.password_enc,
notesEnc: payload.notes !== undefined && payload.notes !== '' ? encrypt(payload.notes) : entry.notes_enc,
iconKey: sanitizeIconKey(payload.iconKey),
groupId,
});
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id });
return stripSensitive(updated);
@@ -126,6 +155,58 @@ export async function removeEntry(id, currentUser) {
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_entry', entityId: id });
}
// --- Grupos (carpetas de un solo nivel) ---
export const listGroups = (currentUser) => repo.findGroupsByUser(currentUser.id);
export async function createGroup(payload, currentUser) {
const name = payload.name?.trim();
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
const group = await repo.createGroup(currentUser.id, name, sanitizeIconKey(payload.iconKey));
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_group', entityId: group.id });
return group;
}
export async function updateGroup(id, payload, currentUser) {
await getOwnGroupOr404(id, currentUser);
const name = payload.name?.trim();
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
const group = await repo.updateGroup(id, name, sanitizeIconKey(payload.iconKey));
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_group', entityId: id });
return group;
}
export async function removeGroup(id, currentUser) {
await getOwnGroupOr404(id, currentUser);
await repo.removeGroup(id);
await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_group', entityId: id });
}
// --- Compartir una entrada (solo lectura) ---
export async function listEntryShares(id, currentUser) {
await getOwnEntryOr404(id, currentUser);
return repo.listShares(id);
}
export async function shareEntry(id, targetUserId, currentUser) {
await getOwnEntryOr404(id, currentUser);
if (!targetUserId) throw new HttpError(400, 'Indica un usuario');
if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir una entrada contigo mismo');
const targetUser = await usersRepo.findById(targetUserId);
if (!targetUser) throw new HttpError(404, 'Usuario no encontrado');
const shares = await repo.addShare(id, targetUserId);
await logAudit({ userId: currentUser.id, action: 'share', entityType: 'vault_entry', entityId: id, detail: { userId: targetUserId } });
return shares;
}
export async function unshareEntry(id, targetUserId, currentUser) {
await getOwnEntryOr404(id, currentUser);
const shares = await repo.removeShare(id, targetUserId);
await logAudit({ userId: currentUser.id, action: 'unshare', entityType: 'vault_entry', entityId: id, detail: { userId: targetUserId } });
return shares;
}
// --- PIN ---
export async function getStatus(currentUser) {