diff --git a/CHANGELOG.md b/CHANGELOG.md index a38812f..83ce103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog - project.linkedcorp.net +## [2.13.0] - 2026-10-14 +### Added +- Contraseñas: grupos de un solo nivel (ej. "Correos") para organizar las credenciales — se + muestran como tarjetas en la vista inicial, clic entra a ver solo las de ese grupo. +- Contraseñas: compartir una credencial individual (nunca un grupo completo) de solo lectura con + otro usuario registrado — puede revelar y copiar la contraseña real, pero nunca + editarla/borrarla/compartirla ni moverla de grupo. + ## [2.12.1] - 2026-10-14 ### Added - Documentos (proyectos y actividades): las imágenes (.png, .jpg, .webp) también se pueden ver diff --git a/VERSION b/VERSION index 3cf561c..fb2c076 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.12.1 +2.13.0 diff --git a/backend/database/migrations/022_vault_groups_sharing.sql b/backend/database/migrations/022_vault_groups_sharing.sql new file mode 100644 index 0000000..097a6a0 --- /dev/null +++ b/backend/database/migrations/022_vault_groups_sharing.sql @@ -0,0 +1,45 @@ +-- Grupos de un solo nivel para Contraseñas (ej. "Correos" agrupa Gmail, Outlook...) y compartir +-- una entrada individual de solo lectura con otro usuario ya registrado. Idempotente. + +CREATE TABLE IF NOT EXISTS vault_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(80) NOT NULL, + icon_key VARCHAR(40) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_vault_groups_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX IF NOT EXISTS idx_vault_groups_user ON vault_groups(user_id); + +-- Borrar un grupo desagrupa sus entradas (ON DELETE SET NULL) -- nunca las borra. +ALTER TABLE vault_entries + ADD COLUMN IF NOT EXISTS group_id INT NULL AFTER user_id; +CREATE INDEX IF NOT EXISTS idx_vault_entries_group ON vault_entries(group_id); + +-- MariaDB no soporta "ADD CONSTRAINT IF NOT EXISTS ... FOREIGN KEY" (error de sintaxis en +-- 10.11) -- se emula la idempotencia a mano consultando information_schema antes de ejecutar el +-- ALTER via PREPARE/EXECUTE (migrate-db.js corre TODOS los .sql en cada deploy, sin tabla de +-- control de versiones, asi que esto debe poder correr muchas veces sin fallar). +SET @fk_exists := ( + SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'vault_entries' AND CONSTRAINT_NAME = 'fk_vault_entries_group' +); +SET @fk_ddl := IF(@fk_exists = 0, + 'ALTER TABLE vault_entries ADD CONSTRAINT fk_vault_entries_group FOREIGN KEY (group_id) REFERENCES vault_groups(id) ON DELETE SET NULL', + 'SELECT 1'); +PREPARE stmt FROM @fk_ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Compartir SOLO por entrada (nunca por grupo completo), de solo lectura: el receptor puede +-- revelar la contraseña real (ver vault/service.js#getReadableEntryOr404) pero nunca +-- editar/borrar/compartir/mover de grupo la entrada ajena. +CREATE TABLE IF NOT EXISTS vault_entry_shares ( + entry_id INT NOT NULL, + user_id INT NOT NULL, + shared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (entry_id, user_id), + CONSTRAINT fk_vault_shares_entry FOREIGN KEY (entry_id) REFERENCES vault_entries(id) ON DELETE CASCADE, + CONSTRAINT fk_vault_shares_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package.json b/backend/package.json index ea2e756..348014a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.12.1", + "version": "2.13.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/vault/controller.js b/backend/src/modules/vault/controller.js index d5a24c1..c2e9463 100644 --- a/backend/src/modules/vault/controller.js +++ b/backend/src/modules/vault/controller.js @@ -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)); +}); diff --git a/backend/src/modules/vault/repository.js b/backend/src/modules/vault/repository.js index b59c9c8..2271a58 100644 --- a/backend/src/modules/vault/repository.js +++ b/backend/src/modules/vault/repository.js @@ -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) { diff --git a/backend/src/modules/vault/routes.js b/backend/src/modules/vault/routes.js index 83835e8..7b67e66 100644 --- a/backend/src/modules/vault/routes.js +++ b/backend/src/modules/vault/routes.js @@ -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; diff --git a/backend/src/modules/vault/service.js b/backend/src/modules/vault/service.js index 0eee280..8605bb3 100644 --- a/backend/src/modules/vault/service.js +++ b/backend/src/modules/vault/service.js @@ -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) { diff --git a/frontend/package.json b/frontend/package.json index 9060e40..ffffbe3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.12.1", + "version": "2.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 94436ea..df33d63 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -222,6 +222,18 @@ export const messages = { vault_copied: 'Copiado al portapapeles', vault_generate: 'Generar', vault_auto_lock_notice: 'Saliste de Contraseñas por seguridad — ingresa tu PIN de nuevo para volver a entrar.', + vault_group_new: 'Nuevo grupo', + vault_group_name: 'Nombre del grupo', + 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_shared_by: 'Compartida por', notes_title: 'Notas', notes_placeholder: 'Escribe un apunte rápido...', notes_empty: 'No tienes notas todavía.', @@ -565,6 +577,18 @@ export const messages = { vault_copied: 'Copied to clipboard', vault_generate: 'Generate', vault_auto_lock_notice: 'You left Passwords for security — enter your PIN again to come back in.', + vault_group_new: 'New group', + vault_group_name: 'Group name', + 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_shared_by: 'Shared by', notes_title: 'Notes', notes_placeholder: 'Write a quick note...', notes_empty: 'You don\'t have any notes yet.', diff --git a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx index f4a3b45..8f9795c 100644 --- a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx +++ b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx @@ -1,33 +1,39 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { - Plus, ChevronUp, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert, MoreVertical, - Mail, Globe, Server, Database, Cloud, Shield, Lock, Wifi, CreditCard, Building2, Printer, - Smartphone, KeyRound, HardDrive, Router, UserCog, + Plus, ChevronUp, ChevronLeft, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert, + MoreVertical, Mail, Globe, Server, Database, Cloud, Shield, Lock, Wifi, CreditCard, Building2, Printer, + Smartphone, KeyRound, HardDrive, Router, UserCog, Folder, FolderPlus, Share2, UserPlus, } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; -import { useAction } from '../../hooks/useAsync.js'; +import { useAction, useAsync } from '../../hooks/useAsync.js'; import { useDebouncedValue } from '../../hooks/useDebouncedValue.js'; import { getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin, setVaultToken, listVaultEntries, getVaultEntry, createVaultEntry, updateVaultEntry, deleteVaultEntry, + listVaultGroups, createVaultGroup, updateVaultGroup, deleteVaultGroup, + listEntryShares, addEntryShare, removeEntryShare, } from '../../services/vault.js'; +import { listUsers } from '../../services/users.js'; import { Button } from '../../components/Button.jsx'; import { Field } from '../../components/Field.jsx'; import { PasswordInput } from '../../components/PasswordInput.jsx'; import { DetailPopup } from '../../components/DetailPopup.jsx'; import { Flyout, FlyoutItem } from '../../components/Flyout.jsx'; import { SearchInput } from '../../components/SearchInput.jsx'; +import { ComboBox } from '../../components/ComboBox.jsx'; import { BRAND_ICONS } from '../../data/brandIcons.js'; -const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '', iconKey: '' }; +const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '', iconKey: '', groupId: '' }; +const EMPTY_GROUP_FORM = { name: '', iconKey: '' }; const AUTO_LOCK_MS = 3 * 60 * 1000; // 3 minutos: salir solo del menu si queda abierto sin atencion // Catalogo fijo de iconos genericos (debe coincidir con backend/src/modules/vault/service.js#ICON_KEYS) -- // a proposito NO se usa ningun servicio externo de favicon (ej. google/s2/favicons): revelaria a // terceros que urls internas tiene guardadas cada usuario, inaceptable para un repositorio de // credenciales. El usuario elige uno de esta lista, o uno de marca (BRAND_ICONS), o se deja -// "Auto" (inicial del nombre). +// "Auto" (inicial del nombre). Los grupos solo usan este catalogo generico (nunca marcas: un +// grupo no es un sitio puntual). const ICONS = { mail: Mail, globe: Globe, server: Server, database: Database, cloud: Cloud, shield: Shield, lock: Lock, wifi: Wifi, card: CreditCard, building: Building2, printer: Printer, @@ -52,6 +58,11 @@ function EntryIcon({ iconKey, title, size = 22 }) { ); } +function GroupIcon({ iconKey, size = 22 }) { + const Icon = iconKey ? ICONS[iconKey] : null; + return
{Icon ? : }
; +} + function randomPassword(length = 20) { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'; const bytes = crypto.getRandomValues(new Uint32Array(length)); @@ -67,8 +78,11 @@ export default function ContrasenasPage() { const [checking, setChecking] = useState(true); const [hasPinSet, setHasPinSet] = useState(null); const [entries, setEntries] = useState(null); + const [groups, setGroups] = useState(null); + const [activeGroupId, setActiveGroupId] = useState(null); const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search, 300); + const { data: users } = useAsync(listUsers, []); const [pin, setPin] = useState(''); const [pinConfirm, setPinConfirm] = useState(''); @@ -82,6 +96,12 @@ export default function ContrasenasPage() { const [passwordVisible, setPasswordVisible] = useState(false); const [creating, setCreating] = useState(false); const [showBrandIcons, setShowBrandIcons] = useState(false); + const [shares, setShares] = useState([]); + const [sharingWith, setSharingWith] = useState(''); + + const [groupPopupOpen, setGroupPopupOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [groupForm, setGroupForm] = useState(EMPTY_GROUP_FORM); const [changingPin, setChangingPin] = useState(false); const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' }); @@ -93,10 +113,15 @@ export default function ContrasenasPage() { setLocked(false); } + async function loadGroups() { + setGroups(await listVaultGroups()); + } + async function checkAccess() { setChecking(true); try { await loadEntries(); + await loadGroups(); } catch { setLocked(true); try { @@ -146,6 +171,7 @@ export default function ContrasenasPage() { await unlockVault(pin); setPin(''); setPinConfirm(''); await loadEntries(); + await loadGroups(); } catch (err) { setPinError(err.message); } finally { @@ -174,24 +200,32 @@ export default function ContrasenasPage() { } } - function openEntry(entry) { + async function openEntry(entry) { setSelected(entry); - setForm({ title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '', iconKey: entry.icon_key || '' }); + setForm({ + title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '', + iconKey: entry.icon_key || '', groupId: entry.group_id ? String(entry.group_id) : '', + }); setRevealedPassword(null); setPasswordVisible(false); setEditing(false); setCreating(false); setShowBrandIcons(!!BRAND_ICONS[entry.icon_key]); + setSharingWith(''); + const isOwner = entry.is_owner !== false; + setShares(isOwner ? await listEntryShares(entry.id) : []); } function openCreate() { setSelected(null); - setForm(EMPTY_FORM); + setForm({ ...EMPTY_FORM, groupId: activeGroupId ? String(activeGroupId) : '' }); setRevealedPassword(null); setPasswordVisible(false); setEditing(true); setCreating(true); setShowBrandIcons(false); + setShares([]); + setSharingWith(''); } function closePopup() { @@ -222,16 +256,17 @@ export default function ContrasenasPage() { async function save() { if (!form.title.trim()) return; + const payload = { ...form, groupId: form.groupId ? Number(form.groupId) : null }; await runAction(async () => { if (creating) { - await createVaultEntry(form); + await createVaultEntry(payload); pushToast({ tone: 'success', message: t('common_saved') }); } else { - await updateVaultEntry(selected.id, form); + await updateVaultEntry(selected.id, payload); pushToast({ tone: 'success', message: t('common_saved') }); } closePopup(); - await loadEntries(); + await Promise.all([loadEntries(), loadGroups()]); }); } @@ -246,7 +281,7 @@ export default function ContrasenasPage() { label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await deleteVaultEntry(entry.id); - await loadEntries(); + await Promise.all([loadEntries(), loadGroups()]); pushToast({ tone: 'success', message: t('common_saved') }); }), }, @@ -259,6 +294,73 @@ export default function ContrasenasPage() { pushToast({ tone: 'success', message: t('vault_copied') }); } + async function addShare() { + if (!sharingWith) return; + await runAction(async () => { + setShares(await addEntryShare(selected.id, Number(sharingWith))); + setSharingWith(''); + await loadEntries(); // el icono de compartida de la tarjeta usa share_count de la lista principal + }); + } + + function removeShare(userId) { + runAction(async () => { + setShares(await removeEntryShare(selected.id, userId)); + await loadEntries(); + }); + } + + function openCreateGroup() { + setEditingGroup(null); + setGroupForm(EMPTY_GROUP_FORM); + setGroupPopupOpen(true); + } + + function openEditGroup(group) { + setEditingGroup(group); + setGroupForm({ name: group.name, iconKey: group.icon_key || '' }); + setGroupPopupOpen(true); + } + + function closeGroupPopup() { + setGroupPopupOpen(false); + setEditingGroup(null); + } + + async function saveGroup() { + if (!groupForm.name.trim()) return; + await runAction(async () => { + if (editingGroup) { + await updateVaultGroup(editingGroup.id, groupForm); + } else { + await createVaultGroup(groupForm); + } + pushToast({ tone: 'success', message: t('common_saved') }); + closeGroupPopup(); + await loadGroups(); + }); + } + + function confirmDeleteGroup(group) { + openDialog({ + tone: 'warning', + title: t('common_confirm_delete_title'), + message: t('vault_group_delete_confirm_msg'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_delete'), variant: 'danger', + onClick: () => runAction(async () => { + await deleteVaultGroup(group.id); + if (activeGroupId === group.id) setActiveGroupId(null); + await Promise.all([loadGroups(), loadEntries()]); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + if (checking) return
{t('common_loading')}
; if (locked) { @@ -300,6 +402,17 @@ export default function ContrasenasPage() { ); } + const query = search.trim(); + const inGroupView = !query && activeGroupId !== null; + const activeGroup = inGroupView ? (groups || []).find((g) => g.id === activeGroupId) : null; + const displayedGroups = !query && activeGroupId === null ? (groups || []) : []; + const displayedEntries = query + ? entries + : inGroupView + ? (entries || []).filter((e) => e.group_id === activeGroupId) + : (entries || []).filter((e) => !e.group_id); + const canEditSelected = creating || (selected && selected.is_owner !== false); + return (
@@ -307,28 +420,68 @@ export default function ContrasenasPage() {
+ {activeGroupId === null && ( + + )}
- {entries.length === 0 && ( -
{search.trim() ? t('common_search_no_results') : t('common_empty')}
+ {inGroupView && activeGroup && ( +
+ + +

{activeGroup.name}

+
+ )} + + {displayedGroups.length === 0 && displayedEntries.length === 0 && ( +
{query ? t('common_search_no_results') : t('common_empty')}
)}
- {entries.map((entry) => ( -
openEntry(entry)}> + {displayedGroups.map((group) => ( +
setActiveGroupId(group.id)}>
e.stopPropagation()}> ( )}> - confirmDelete(entry)}>{t('common_delete')} + openEditGroup(group)}>{t('common_edit')} + confirmDeleteGroup(group)}>{t('common_delete')}
- -
{entry.title}
- {entry.url &&
{entry.url}
} + +
{group.name}
+
{group.entry_count} {t('vault_group_entries_count')}
))} + {displayedEntries.map((entry) => { + const isOwner = entry.is_owner !== false; + return ( +
openEntry(entry)}> + {isOwner ? ( +
e.stopPropagation()}> + ( + + )}> + confirmDelete(entry)}>{t('common_delete')} + +
+ ) : ( + + + + )} + {isOwner && entry.share_count > 0 && ( + + + + )} + +
{entry.title}
+ {entry.url &&
{entry.url}
} +
+ ); + })}
+ {!creating && selected && selected.is_owner === false && ( +
+ {t('vault_shared_by')} {selected.owner_name} +
+ )} + {editing && (
@@ -400,6 +559,16 @@ export default function ContrasenasPage() { )} + {editing && canEditSelected && ( + + ({ value: String(g.id), label: g.name }))]} + value={form.groupId} + onChange={(v) => setForm((f) => ({ ...f, groupId: v }))} + /> + + )} + setForm((f) => ({ ...f, title: e.target.value }))} /> @@ -441,15 +610,81 @@ export default function ContrasenasPage() {