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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-14 16:14:36 -05:00
parent 971de05773
commit 3da652c136
13 changed files with 570 additions and 43 deletions
+8
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
2.12.1 2.13.0
@@ -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 -1
View File
@@ -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",
+29
View File
@@ -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));
});
+92 -8
View File
@@ -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) {
+11
View File
@@ -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;
+84 -3
View File
@@ -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 -1
View File
@@ -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",
+24
View File
@@ -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,16 +420,45 @@ 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={`g${group.id}`} className="vault-card" onClick={() => setActiveGroupId(group.id)}>
<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={Pencil} onClick={() => openEditGroup(group)}>{t('common_edit')}</FlyoutItem>
<FlyoutItem icon={Trash2} danger onClick={() => confirmDeleteGroup(group)}>{t('common_delete')}</FlyoutItem>
</Flyout>
</div>
<GroupIcon iconKey={group.icon_key} />
<div className="vault-card-title">{group.name}</div>
<div className="vault-card-url">{group.entry_count} {t('vault_group_entries_count')}</div>
</div>
))}
{displayedEntries.map((entry) => {
const isOwner = entry.is_owner !== false;
return (
<div key={entry.id} className="vault-card" onClick={() => openEntry(entry)}> <div key={entry.id} className="vault-card" onClick={() => openEntry(entry)}>
{isOwner ? (
<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>
@@ -324,11 +466,22 @@ export default function ContrasenasPage() {
<FlyoutItem icon={Trash2} danger onClick={() => confirmDelete(entry)}>{t('common_delete')}</FlyoutItem> <FlyoutItem icon={Trash2} danger onClick={() => confirmDelete(entry)}>{t('common_delete')}</FlyoutItem>
</Flyout> </Flyout>
</div> </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} /> <EntryIcon iconKey={entry.icon_key} title={entry.title} />
<div className="vault-card-title">{entry.title}</div> <div className="vault-card-title">{entry.title}</div>
{entry.url && <div className="vault-card-url">{entry.url}</div>} {entry.url && <div className="vault-card-url">{entry.url}</div>}
</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,6 +610,7 @@ 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 }}> <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
{editing ? ( {editing ? (
<> <>
@@ -451,6 +621,71 @@ export default function ContrasenasPage() {
<Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button> <Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button>
)} )}
</div> </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 }}>
<Button variant="accent" icon={Save} onClick={saveGroup} disabled={!groupForm.name.trim()}>{t('common_save')}</Button>
<Button variant="standard" icon={X} onClick={closeGroupPopup}>{t('common_cancel')}</Button>
</div>
</DetailPopup> </DetailPopup>
<DetailPopup open={changingPin} title={t('vault_change_pin')} onClose={() => setChangingPin(false)} width={360} height={360}> <DetailPopup open={changingPin} title={t('vault_change_pin')} onClose={() => setChangingPin(false)} width={360} height={360}>
+9
View File
@@ -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());
+1
View File
@@ -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