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:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.12.1] - 2026-10-14
|
||||||
### Added
|
### Added
|
||||||
- Documentos (proyectos y actividades): las imágenes (.png, .jpg, .webp) también se pueden ver
|
- Documentos (proyectos y actividades): las imágenes (.png, .jpg, .webp) también se pueden ver
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.12.1",
|
"version": "2.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -49,3 +49,32 @@ export const deleteEntry = asyncHandler(async (req, res) => {
|
|||||||
await service.removeEntry(Number(req.params.id), req.currentUser);
|
await service.removeEntry(Number(req.params.id), req.currentUser);
|
||||||
ok(res, { deleted: true });
|
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';
|
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`;
|
const FULL_SELECT = `${LIST_SELECT}, password_enc, notes_enc`;
|
||||||
|
|
||||||
export async function findByUser(userId) {
|
export async function findByUser(userId) {
|
||||||
@@ -21,24 +21,46 @@ export async function findByUserForSearch(userId) {
|
|||||||
return rows;
|
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) {
|
export async function findById(id) {
|
||||||
const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]);
|
const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]);
|
||||||
return rows[0] || null;
|
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(
|
const [result] = await pool.query(
|
||||||
`INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc, icon_key)
|
`INSERT INTO vault_entries (user_id, group_id, title, url, username, password_enc, notes_enc, icon_key)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[userId, title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null]
|
[userId, groupId || null, title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null]
|
||||||
);
|
);
|
||||||
return findById(result.insertId);
|
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(
|
await pool.query(
|
||||||
`UPDATE vault_entries SET title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ?, icon_key = ? WHERE id = ?`,
|
`UPDATE vault_entries SET group_id = ?, title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ?, icon_key = ? WHERE id = ?`,
|
||||||
[title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null, id]
|
[groupId || null, title, url || null, username || null, passwordEnc || null, notesEnc || null, iconKey || null, id]
|
||||||
);
|
);
|
||||||
return findById(id);
|
return findById(id);
|
||||||
}
|
}
|
||||||
@@ -47,6 +69,68 @@ export async function remove(id) {
|
|||||||
await pool.query('DELETE FROM vault_entries WHERE id = ?', [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) ---
|
// --- PIN de acceso al modulo (en la tabla users, ver migracion 017) ---
|
||||||
|
|
||||||
export async function getVaultPinHash(userId) {
|
export async function getVaultPinHash(userId) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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,
|
||||||
|
getEntryShares, postEntryShare, deleteEntryShare,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -22,10 +24,19 @@ router.post('/unlock', unlockLimiter, postUnlock);
|
|||||||
router.post('/change-pin', unlockLimiter, postChangePin);
|
router.post('/change-pin', unlockLimiter, postChangePin);
|
||||||
router.post('/forgot-pin', unlockLimiter, postForgotPin);
|
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', requireVaultUnlock, getEntries);
|
||||||
router.get('/entries/:id', requireVaultUnlock, getEntry);
|
router.get('/entries/:id', requireVaultUnlock, getEntry);
|
||||||
router.post('/entries', requireVaultUnlock, postEntry);
|
router.post('/entries', requireVaultUnlock, postEntry);
|
||||||
router.put('/entries/:id', requireVaultUnlock, putEntry);
|
router.put('/entries/:id', requireVaultUnlock, putEntry);
|
||||||
router.delete('/entries/:id', requireVaultUnlock, deleteEntry);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import * as usersRepo from '../users/repository.js';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { encrypt, decrypt } from '../../utils/crypto.js';
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
@@ -47,6 +48,22 @@ async function getOwnEntryOr404(id, currentUser) {
|
|||||||
return entry;
|
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) {
|
function stripSensitive(entry) {
|
||||||
const { password_enc, notes_enc, ...rest } = entry;
|
const { password_enc, notes_enc, ...rest } = entry;
|
||||||
return rest;
|
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.
|
// 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) {
|
export async function listEntries(currentUser, query) {
|
||||||
const q = query?.trim().toLowerCase();
|
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);
|
const rows = await repo.findByUserForSearch(currentUser.id);
|
||||||
return rows
|
return rows
|
||||||
@@ -82,15 +99,24 @@ export async function listEntries(currentUser, query) {
|
|||||||
.map(stripSensitive);
|
.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) {
|
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 });
|
await logAudit({ userId: currentUser.id, action: 'view', entityType: 'vault_entry', entityId: id });
|
||||||
return decryptEntry(entry);
|
return decryptEntry(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveGroupId(groupId, currentUser) {
|
||||||
|
if (!groupId) return null;
|
||||||
|
await getOwnGroupOr404(groupId, currentUser);
|
||||||
|
return groupId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createEntry(payload, currentUser) {
|
export async function createEntry(payload, currentUser) {
|
||||||
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
|
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, {
|
const entry = await repo.create(currentUser.id, {
|
||||||
title: payload.title.trim(),
|
title: payload.title.trim(),
|
||||||
url: payload.url?.trim(),
|
url: payload.url?.trim(),
|
||||||
@@ -98,6 +124,7 @@ export async function createEntry(payload, currentUser) {
|
|||||||
passwordEnc: payload.password ? encrypt(payload.password) : null,
|
passwordEnc: payload.password ? encrypt(payload.password) : null,
|
||||||
notesEnc: payload.notes ? encrypt(payload.notes) : null,
|
notesEnc: payload.notes ? encrypt(payload.notes) : null,
|
||||||
iconKey: sanitizeIconKey(payload.iconKey),
|
iconKey: sanitizeIconKey(payload.iconKey),
|
||||||
|
groupId,
|
||||||
});
|
});
|
||||||
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id });
|
await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id });
|
||||||
return stripSensitive(entry);
|
return stripSensitive(entry);
|
||||||
@@ -106,6 +133,7 @@ export async function createEntry(payload, currentUser) {
|
|||||||
export async function updateEntry(id, payload, currentUser) {
|
export async function updateEntry(id, payload, currentUser) {
|
||||||
const entry = await getOwnEntryOr404(id, currentUser);
|
const entry = await getOwnEntryOr404(id, currentUser);
|
||||||
if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio');
|
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"
|
// 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.
|
// primero), se conserva lo que ya estaba guardado -- nunca se borra por venir vacio.
|
||||||
const updated = await repo.update(id, {
|
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,
|
passwordEnc: payload.password ? encrypt(payload.password) : entry.password_enc,
|
||||||
notesEnc: payload.notes !== undefined && payload.notes !== '' ? encrypt(payload.notes) : entry.notes_enc,
|
notesEnc: payload.notes !== undefined && payload.notes !== '' ? encrypt(payload.notes) : entry.notes_enc,
|
||||||
iconKey: sanitizeIconKey(payload.iconKey),
|
iconKey: sanitizeIconKey(payload.iconKey),
|
||||||
|
groupId,
|
||||||
});
|
});
|
||||||
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id });
|
await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id });
|
||||||
return stripSensitive(updated);
|
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 });
|
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 ---
|
// --- 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.12.1",
|
"version": "2.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -222,6 +222,18 @@ export const messages = {
|
|||||||
vault_copied: 'Copiado al portapapeles',
|
vault_copied: 'Copiado al portapapeles',
|
||||||
vault_generate: 'Generar',
|
vault_generate: 'Generar',
|
||||||
vault_auto_lock_notice: 'Saliste de Contraseñas por seguridad — ingresa tu PIN de nuevo para volver a entrar.',
|
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_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.',
|
||||||
@@ -565,6 +577,18 @@ export const messages = {
|
|||||||
vault_copied: 'Copied to clipboard',
|
vault_copied: 'Copied to clipboard',
|
||||||
vault_generate: 'Generate',
|
vault_generate: 'Generate',
|
||||||
vault_auto_lock_notice: 'You left Passwords for security — enter your PIN again to come back in.',
|
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_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.',
|
||||||
|
|||||||
@@ -1,33 +1,39 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Plus, ChevronUp, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert, MoreVertical,
|
Plus, ChevronUp, ChevronLeft, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert,
|
||||||
Mail, Globe, Server, Database, Cloud, Shield, Lock, Wifi, CreditCard, Building2, Printer,
|
MoreVertical, Mail, Globe, Server, Database, Cloud, Shield, Lock, Wifi, CreditCard, Building2, Printer,
|
||||||
Smartphone, KeyRound, HardDrive, Router, UserCog,
|
Smartphone, KeyRound, HardDrive, Router, UserCog, Folder, FolderPlus, Share2, UserPlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
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 { 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,
|
||||||
|
listEntryShares, addEntryShare, removeEntryShare,
|
||||||
} from '../../services/vault.js';
|
} from '../../services/vault.js';
|
||||||
|
import { listUsers } from '../../services/users.js';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
import { Field } from '../../components/Field.jsx';
|
import { Field } from '../../components/Field.jsx';
|
||||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
import { Flyout, FlyoutItem } from '../../components/Flyout.jsx';
|
import { Flyout, FlyoutItem } from '../../components/Flyout.jsx';
|
||||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { BRAND_ICONS } from '../../data/brandIcons.js';
|
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
|
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) --
|
// 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
|
// 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
|
// 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
|
// 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 = {
|
const ICONS = {
|
||||||
mail: Mail, globe: Globe, server: Server, database: Database, cloud: Cloud, shield: Shield,
|
mail: Mail, globe: Globe, server: Server, database: Database, cloud: Cloud, shield: Shield,
|
||||||
lock: Lock, wifi: Wifi, card: CreditCard, building: Building2, printer: Printer,
|
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 <div className="vault-card-icon">{Icon ? <Icon size={size} /> : <Folder size={size} />}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function randomPassword(length = 20) {
|
function randomPassword(length = 20) {
|
||||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
|
||||||
const bytes = crypto.getRandomValues(new Uint32Array(length));
|
const bytes = crypto.getRandomValues(new Uint32Array(length));
|
||||||
@@ -67,8 +78,11 @@ export default function ContrasenasPage() {
|
|||||||
const [checking, setChecking] = useState(true);
|
const [checking, setChecking] = useState(true);
|
||||||
const [hasPinSet, setHasPinSet] = useState(null);
|
const [hasPinSet, setHasPinSet] = useState(null);
|
||||||
const [entries, setEntries] = useState(null);
|
const [entries, setEntries] = useState(null);
|
||||||
|
const [groups, setGroups] = useState(null);
|
||||||
|
const [activeGroupId, setActiveGroupId] = useState(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const debouncedSearch = useDebouncedValue(search, 300);
|
const debouncedSearch = useDebouncedValue(search, 300);
|
||||||
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
|
||||||
const [pin, setPin] = useState('');
|
const [pin, setPin] = useState('');
|
||||||
const [pinConfirm, setPinConfirm] = useState('');
|
const [pinConfirm, setPinConfirm] = useState('');
|
||||||
@@ -82,6 +96,12 @@ export default function ContrasenasPage() {
|
|||||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [showBrandIcons, setShowBrandIcons] = 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 [changingPin, setChangingPin] = useState(false);
|
||||||
const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' });
|
const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' });
|
||||||
@@ -93,10 +113,15 @@ export default function ContrasenasPage() {
|
|||||||
setLocked(false);
|
setLocked(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
setGroups(await listVaultGroups());
|
||||||
|
}
|
||||||
|
|
||||||
async function checkAccess() {
|
async function checkAccess() {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
try {
|
try {
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
|
await loadGroups();
|
||||||
} catch {
|
} catch {
|
||||||
setLocked(true);
|
setLocked(true);
|
||||||
try {
|
try {
|
||||||
@@ -146,6 +171,7 @@ export default function ContrasenasPage() {
|
|||||||
await unlockVault(pin);
|
await unlockVault(pin);
|
||||||
setPin(''); setPinConfirm('');
|
setPin(''); setPinConfirm('');
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
|
await loadGroups();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPinError(err.message);
|
setPinError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -174,24 +200,32 @@ export default function ContrasenasPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEntry(entry) {
|
async function openEntry(entry) {
|
||||||
setSelected(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);
|
setRevealedPassword(null);
|
||||||
setPasswordVisible(false);
|
setPasswordVisible(false);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setShowBrandIcons(!!BRAND_ICONS[entry.icon_key]);
|
setShowBrandIcons(!!BRAND_ICONS[entry.icon_key]);
|
||||||
|
setSharingWith('');
|
||||||
|
const isOwner = entry.is_owner !== false;
|
||||||
|
setShares(isOwner ? await listEntryShares(entry.id) : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setForm(EMPTY_FORM);
|
setForm({ ...EMPTY_FORM, groupId: activeGroupId ? String(activeGroupId) : '' });
|
||||||
setRevealedPassword(null);
|
setRevealedPassword(null);
|
||||||
setPasswordVisible(false);
|
setPasswordVisible(false);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setShowBrandIcons(false);
|
setShowBrandIcons(false);
|
||||||
|
setShares([]);
|
||||||
|
setSharingWith('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePopup() {
|
function closePopup() {
|
||||||
@@ -222,16 +256,17 @@ export default function ContrasenasPage() {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.title.trim()) return;
|
if (!form.title.trim()) return;
|
||||||
|
const payload = { ...form, groupId: form.groupId ? Number(form.groupId) : null };
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
if (creating) {
|
if (creating) {
|
||||||
await createVaultEntry(form);
|
await createVaultEntry(payload);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
} else {
|
} else {
|
||||||
await updateVaultEntry(selected.id, form);
|
await updateVaultEntry(selected.id, payload);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
}
|
}
|
||||||
closePopup();
|
closePopup();
|
||||||
await loadEntries();
|
await Promise.all([loadEntries(), loadGroups()]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +281,7 @@ export default function ContrasenasPage() {
|
|||||||
label: t('common_delete'), variant: 'danger',
|
label: t('common_delete'), variant: 'danger',
|
||||||
onClick: () => runAction(async () => {
|
onClick: () => runAction(async () => {
|
||||||
await deleteVaultEntry(entry.id);
|
await deleteVaultEntry(entry.id);
|
||||||
await loadEntries();
|
await Promise.all([loadEntries(), loadGroups()]);
|
||||||
pushToast({ tone: 'success', message: t('common_saved') });
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -259,6 +294,73 @@ export default function ContrasenasPage() {
|
|||||||
pushToast({ tone: 'success', message: t('vault_copied') });
|
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 <div className="text-muted">{t('common_loading')}</div>;
|
if (checking) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
|
||||||
if (locked) {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
@@ -307,28 +420,68 @@ export default function ContrasenasPage() {
|
|||||||
<SearchInput value={search} onChange={setSearch} />
|
<SearchInput value={search} onChange={setSearch} />
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button>
|
<Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button>
|
||||||
|
{activeGroupId === null && (
|
||||||
|
<Button variant="standard" icon={FolderPlus} onClick={openCreateGroup}>{t('vault_group_new')}</Button>
|
||||||
|
)}
|
||||||
<Button variant="accent" icon={Plus} onClick={openCreate}>{t('vault_new_entry')}</Button>
|
<Button variant="accent" icon={Plus} onClick={openCreate}>{t('vault_new_entry')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entries.length === 0 && (
|
{inGroupView && activeGroup && (
|
||||||
<div className="text-muted">{search.trim() ? t('common_search_no_results') : t('common_empty')}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||||
|
<button className="icon-btn" title={t('vault_group_back')} onClick={() => setActiveGroupId(null)}><ChevronLeft size={18} /></button>
|
||||||
|
<GroupIcon iconKey={activeGroup.icon_key} size={16} />
|
||||||
|
<h3 style={{ margin: 0 }}>{activeGroup.name}</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayedGroups.length === 0 && displayedEntries.length === 0 && (
|
||||||
|
<div className="text-muted">{query ? t('common_search_no_results') : t('common_empty')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="vault-grid">
|
<div className="vault-grid">
|
||||||
{entries.map((entry) => (
|
{displayedGroups.map((group) => (
|
||||||
<div key={entry.id} className="vault-card" onClick={() => openEntry(entry)}>
|
<div key={`g${group.id}`} className="vault-card" onClick={() => setActiveGroupId(group.id)}>
|
||||||
<div className="vault-card-menu" onClick={(e) => e.stopPropagation()}>
|
<div className="vault-card-menu" onClick={(e) => e.stopPropagation()}>
|
||||||
<Flyout trigger={({ onClick }) => (
|
<Flyout trigger={({ onClick }) => (
|
||||||
<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={Trash2} danger onClick={() => confirmDelete(entry)}>{t('common_delete')}</FlyoutItem>
|
<FlyoutItem icon={Pencil} onClick={() => openEditGroup(group)}>{t('common_edit')}</FlyoutItem>
|
||||||
|
<FlyoutItem icon={Trash2} danger onClick={() => confirmDeleteGroup(group)}>{t('common_delete')}</FlyoutItem>
|
||||||
</Flyout>
|
</Flyout>
|
||||||
</div>
|
</div>
|
||||||
<EntryIcon iconKey={entry.icon_key} title={entry.title} />
|
<GroupIcon iconKey={group.icon_key} />
|
||||||
<div className="vault-card-title">{entry.title}</div>
|
<div className="vault-card-title">{group.name}</div>
|
||||||
{entry.url && <div className="vault-card-url">{entry.url}</div>}
|
<div className="vault-card-url">{group.entry_count} {t('vault_group_entries_count')}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{displayedEntries.map((entry) => {
|
||||||
|
const isOwner = entry.is_owner !== false;
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="vault-card" onClick={() => openEntry(entry)}>
|
||||||
|
{isOwner ? (
|
||||||
|
<div className="vault-card-menu" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Flyout trigger={({ onClick }) => (
|
||||||
|
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical size={16} /></button>
|
||||||
|
)}>
|
||||||
|
<FlyoutItem icon={Trash2} danger onClick={() => confirmDelete(entry)}>{t('common_delete')}</FlyoutItem>
|
||||||
|
</Flyout>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="vault-card-shared-badge" title={`${t('vault_shared_by')} ${entry.owner_name}`}>
|
||||||
|
<Share2 size={13} strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isOwner && entry.share_count > 0 && (
|
||||||
|
<span className="vault-card-shared-badge" title={t('vault_share_title')}>
|
||||||
|
<Share2 size={13} strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<EntryIcon iconKey={entry.icon_key} title={entry.title} />
|
||||||
|
<div className="vault-card-title">{entry.title}</div>
|
||||||
|
{entry.url && <div className="vault-card-url">{entry.url}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailPopup
|
<DetailPopup
|
||||||
@@ -338,6 +491,12 @@ export default function ContrasenasPage() {
|
|||||||
width={480}
|
width={480}
|
||||||
height={640}
|
height={640}
|
||||||
>
|
>
|
||||||
|
{!creating && selected && selected.is_owner === false && (
|
||||||
|
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>
|
||||||
|
{t('vault_shared_by')} {selected.owner_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<Field label={t('vault_field_icon')}>
|
<Field label={t('vault_field_icon')}>
|
||||||
<div className="vault-icon-picker">
|
<div className="vault-icon-picker">
|
||||||
@@ -400,6 +559,16 @@ export default function ContrasenasPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editing && canEditSelected && (
|
||||||
|
<Field label={t('vault_field_group')}>
|
||||||
|
<ComboBox
|
||||||
|
options={[{ value: '', label: t('vault_group_none') }, ...(groups || []).map((g) => ({ value: String(g.id), label: g.name }))]}
|
||||||
|
value={form.groupId}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, groupId: v }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field label={t('vault_field_title')}>
|
<Field label={t('vault_field_title')}>
|
||||||
<input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} />
|
<input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -441,15 +610,81 @@ export default function ContrasenasPage() {
|
|||||||
<textarea className="textarea" rows={3} disabled={!editing} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
<textarea className="textarea" rows={3} disabled={!editing} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{(editing || canEditSelected) && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<Button variant="accent" icon={Save} onClick={save} disabled={!form.title.trim()}>{t('common_save')}</Button>
|
||||||
|
<Button variant="standard" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!creating && canEditSelected && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<h4 style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Share2 size={15} /> {t('vault_share_title')}</h4>
|
||||||
|
<Field label={t('vault_share_add')}>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<ComboBox
|
||||||
|
options={(users || []).filter((u) => !shares.some((s) => s.user_id === u.id)).map((u) => ({ value: u.id, label: u.name }))}
|
||||||
|
value={sharingWith}
|
||||||
|
onChange={setSharingWith}
|
||||||
|
placeholder={t('vault_share_placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="standard" icon={UserPlus} onClick={addShare} disabled={!sharingWith}>{t('vault_share_add')}</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
{shares.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('vault_share_empty')}</div>}
|
||||||
|
{shares.map((s) => (
|
||||||
|
<div key={s.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||||
|
<span style={{ flex: 1, fontSize: '.82rem' }}>{s.name} <span className="text-muted">· {s.email}</span></span>
|
||||||
|
<button className="icon-btn" onClick={() => removeShare(s.user_id)}><Trash2 size={15} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DetailPopup>
|
||||||
|
|
||||||
|
<DetailPopup
|
||||||
|
open={groupPopupOpen}
|
||||||
|
title={editingGroup ? editingGroup.name : t('vault_group_new')}
|
||||||
|
onClose={closeGroupPopup}
|
||||||
|
width={380}
|
||||||
|
height={300}
|
||||||
|
>
|
||||||
|
<Field label={t('vault_group_name')}>
|
||||||
|
<input className="input" autoFocus value={groupForm.name} onChange={(e) => setGroupForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('vault_field_icon')}>
|
||||||
|
<div className="vault-icon-picker">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`vault-icon-option ${!groupForm.iconKey ? 'selected' : ''}`}
|
||||||
|
title={t('vault_icon_auto')}
|
||||||
|
onClick={() => setGroupForm((f) => ({ ...f, iconKey: '' }))}
|
||||||
|
>
|
||||||
|
<Folder size={18} />
|
||||||
|
</button>
|
||||||
|
{Object.entries(ICONS).map(([key, Icon]) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={key}
|
||||||
|
className={`vault-icon-option ${groupForm.iconKey === key ? 'selected' : ''}`}
|
||||||
|
onClick={() => setGroupForm((f) => ({ ...f, iconKey: key }))}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
{editing ? (
|
<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="accent" icon={Save} onClick={save} disabled={!form.title.trim()}>{t('common_save')}</Button>
|
|
||||||
<Button variant="standard" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,12 @@ export const getVaultEntry = (id) => api.get(`/vault/entries/${id}`, vaultHeader
|
|||||||
export const createVaultEntry = (data) => api.post('/vault/entries', data, vaultHeaders());
|
export const createVaultEntry = (data) => api.post('/vault/entries', data, vaultHeaders());
|
||||||
export const updateVaultEntry = (id, data) => api.put(`/vault/entries/${id}`, data, vaultHeaders());
|
export const updateVaultEntry = (id, data) => api.put(`/vault/entries/${id}`, data, vaultHeaders());
|
||||||
export const deleteVaultEntry = (id) => api.delete(`/vault/entries/${id}`, vaultHeaders());
|
export const deleteVaultEntry = (id) => api.delete(`/vault/entries/${id}`, vaultHeaders());
|
||||||
|
|
||||||
|
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 listEntryShares = (id) => api.get(`/vault/entries/${id}/shares`, vaultHeaders());
|
||||||
|
export const addEntryShare = (id, userId) => api.post(`/vault/entries/${id}/shares`, { userId }, vaultHeaders());
|
||||||
|
export const removeEntryShare = (id, userId) => api.delete(`/vault/entries/${id}/shares/${userId}`, vaultHeaders());
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
|||||||
font-size: .72rem; color: rgb(var(--muted)); max-width: 100%; overflow: hidden;
|
font-size: .72rem; color: rgb(var(--muted)); max-width: 100%; overflow: hidden;
|
||||||
text-overflow: ellipsis; white-space: nowrap;
|
text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.vault-card-shared-badge { position: absolute; bottom: 6px; right: 6px; display: flex; align-items: center; color: rgb(var(--accent)); }
|
||||||
/* PIN de 4 digitos: NO es type="password" a proposito -- Chrome ofrece "guardar contraseña"
|
/* PIN de 4 digitos: NO es type="password" a proposito -- Chrome ofrece "guardar contraseña"
|
||||||
(como si fuera login de un sitio) apenas detecta un input password con valor + una llamada de
|
(como si fuera login de un sitio) apenas detecta un input password con valor + una llamada de
|
||||||
red despues, molestando cada vez que se desbloquea el candado. Un PIN no es una credencial de
|
red despues, molestando cada vez que se desbloquea el candado. Un PIN no es una credencial de
|
||||||
|
|||||||
Reference in New Issue
Block a user