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:
@@ -49,3 +49,32 @@ export const deleteEntry = asyncHandler(async (req, res) => {
|
||||
await service.removeEntry(Number(req.params.id), req.currentUser);
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
|
||||
export const getGroups = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.listGroups(req.currentUser));
|
||||
});
|
||||
|
||||
export const postGroup = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createGroup(req.body, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const putGroup = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.updateGroup(Number(req.params.id), req.body, req.currentUser));
|
||||
});
|
||||
|
||||
export const deleteGroup = asyncHandler(async (req, res) => {
|
||||
await service.removeGroup(Number(req.params.id), req.currentUser);
|
||||
ok(res, { deleted: true });
|
||||
});
|
||||
|
||||
export const getEntryShares = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.listEntryShares(Number(req.params.id), req.currentUser));
|
||||
});
|
||||
|
||||
export const postEntryShare = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.shareEntry(Number(req.params.id), Number(req.body.userId), req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const deleteEntryShare = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.unshareEntry(Number(req.params.id), Number(req.params.userId), req.currentUser));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pool } from '../../config/db.js';
|
||||
|
||||
const LIST_SELECT = 'id, user_id, title, url, username, icon_key, created_at, updated_at';
|
||||
const LIST_SELECT = 'id, user_id, group_id, title, url, username, icon_key, created_at, updated_at';
|
||||
const FULL_SELECT = `${LIST_SELECT}, password_enc, notes_enc`;
|
||||
|
||||
export async function findByUser(userId) {
|
||||
@@ -21,24 +21,46 @@ export async function findByUserForSearch(userId) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Propias (con su group_id) MAS las compartidas conmigo (ver vault_entry_shares). Lo compartido
|
||||
// siempre se ve "suelto" (group_id forzado a null): el grupo es organizacion personal del dueño,
|
||||
// no tiene sentido para quien recibe el share -- mismo criterio que notes/repository.js.
|
||||
export async function findVisibleToUser(userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT e.id, e.user_id, e.group_id, e.title, e.url, e.username, e.icon_key, e.created_at, e.updated_at,
|
||||
(e.user_id = ?) AS is_owner, u.name AS owner_name,
|
||||
(SELECT COUNT(*) FROM vault_entry_shares s WHERE s.entry_id = e.id) AS share_count
|
||||
FROM vault_entries e
|
||||
JOIN users u ON u.id = e.user_id
|
||||
WHERE e.user_id = ? OR e.id IN (SELECT entry_id FROM vault_entry_shares WHERE user_id = ?)
|
||||
ORDER BY is_owner DESC, e.title`,
|
||||
[userId, userId, userId]
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
is_owner: !!r.is_owner,
|
||||
group_id: r.is_owner ? r.group_id : null,
|
||||
share_count: Number(r.share_count),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function findById(id) {
|
||||
const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function create(userId, { title, url, username, passwordEnc, notesEnc, iconKey }) {
|
||||
export async function create(userId, { title, url, username, passwordEnc, notesEnc, iconKey, groupId }) {
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc, icon_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId, title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null]
|
||||
`INSERT INTO vault_entries (user_id, group_id, title, url, username, password_enc, notes_enc, icon_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[userId, groupId || null, title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
}
|
||||
|
||||
export async function update(id, { title, url, username, passwordEnc, notesEnc, iconKey }) {
|
||||
export async function update(id, { title, url, username, passwordEnc, notesEnc, iconKey, groupId }) {
|
||||
await pool.query(
|
||||
`UPDATE vault_entries SET title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ?, icon_key = ? WHERE id = ?`,
|
||||
[title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null, id]
|
||||
`UPDATE vault_entries SET group_id = ?, title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ?, icon_key = ? WHERE id = ?`,
|
||||
[groupId || null, title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null, id]
|
||||
);
|
||||
return findById(id);
|
||||
}
|
||||
@@ -47,6 +69,68 @@ export async function remove(id) {
|
||||
await pool.query('DELETE FROM vault_entries WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// --- Grupos (carpetas de un solo nivel) ---
|
||||
|
||||
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
|
||||
FROM vault_groups g WHERE g.user_id = ? ORDER BY g.name`,
|
||||
[userId]
|
||||
);
|
||||
return rows.map((r) => ({ ...r, entry_count: Number(r.entry_count) }));
|
||||
}
|
||||
|
||||
export async function findGroupById(id) {
|
||||
const [rows] = await pool.query('SELECT id, user_id, name, icon_key, created_at, updated_at FROM vault_groups WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function createGroup(userId, name, iconKey) {
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO vault_groups (user_id, name, icon_key) VALUES (?, ?, ?)',
|
||||
[userId, name, iconKey || null]
|
||||
);
|
||||
return findGroupById(result.insertId);
|
||||
}
|
||||
|
||||
export async function updateGroup(id, name, iconKey) {
|
||||
await pool.query('UPDATE vault_groups SET name = ?, icon_key = ? WHERE id = ?', [name, iconKey || null, id]);
|
||||
return findGroupById(id);
|
||||
}
|
||||
|
||||
export async function removeGroup(id) {
|
||||
// El group_id de sus entradas queda en NULL por el ON DELETE SET NULL de la FK.
|
||||
await pool.query('DELETE FROM vault_groups WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// --- Compartir una entrada (solo lectura, ver vault/service.js) ---
|
||||
|
||||
export async function listShares(entryId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.user_id, u.name, u.email, s.shared_at
|
||||
FROM vault_entry_shares s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.entry_id = ? ORDER BY u.name`,
|
||||
[entryId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function isSharedWith(entryId, userId) {
|
||||
const [rows] = await pool.query('SELECT 1 FROM vault_entry_shares WHERE entry_id = ? AND user_id = ?', [entryId, userId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function addShare(entryId, userId) {
|
||||
await pool.query('INSERT IGNORE INTO vault_entry_shares (entry_id, user_id) VALUES (?, ?)', [entryId, userId]);
|
||||
return listShares(entryId);
|
||||
}
|
||||
|
||||
export async function removeShare(entryId, userId) {
|
||||
await pool.query('DELETE FROM vault_entry_shares WHERE entry_id = ? AND user_id = ?', [entryId, userId]);
|
||||
return listShares(entryId);
|
||||
}
|
||||
|
||||
// --- PIN de acceso al modulo (en la tabla users, ver migracion 017) ---
|
||||
|
||||
export async function getVaultPinHash(userId) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { requireVaultUnlock } from './middleware.js';
|
||||
import {
|
||||
getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin,
|
||||
getEntries, getEntry, postEntry, putEntry, deleteEntry,
|
||||
getGroups, postGroup, putGroup, deleteGroup,
|
||||
getEntryShares, postEntryShare, deleteEntryShare,
|
||||
} from './controller.js';
|
||||
|
||||
const router = Router();
|
||||
@@ -22,10 +24,19 @@ router.post('/unlock', unlockLimiter, postUnlock);
|
||||
router.post('/change-pin', unlockLimiter, postChangePin);
|
||||
router.post('/forgot-pin', unlockLimiter, postForgotPin);
|
||||
|
||||
router.get('/groups', requireVaultUnlock, getGroups);
|
||||
router.post('/groups', requireVaultUnlock, postGroup);
|
||||
router.put('/groups/:id', requireVaultUnlock, putGroup);
|
||||
router.delete('/groups/:id', requireVaultUnlock, deleteGroup);
|
||||
|
||||
router.get('/entries', requireVaultUnlock, getEntries);
|
||||
router.get('/entries/:id', requireVaultUnlock, getEntry);
|
||||
router.post('/entries', requireVaultUnlock, postEntry);
|
||||
router.put('/entries/:id', requireVaultUnlock, putEntry);
|
||||
router.delete('/entries/:id', requireVaultUnlock, deleteEntry);
|
||||
|
||||
router.get('/entries/:id/shares', requireVaultUnlock, getEntryShares);
|
||||
router.post('/entries/:id/shares', requireVaultUnlock, postEntryShare);
|
||||
router.delete('/entries/:id/shares/:userId', requireVaultUnlock, deleteEntryShare);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user