diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index e768791..1a461fb 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -40,6 +40,7 @@ | `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) | | `error_logs` | errores de backend (excepciones 500) y frontend (JS/promesas sin catch), panel "Errores del sistema" en Configuración | | `notes` | apuntes rápidos personales, siempre privados de cada usuario, menú propio "Notas" | +| `vault_entries` | repositorio de credenciales privado por usuario (menú "Contraseñas"), contraseña/notas cifradas | ## Relaciones clave @@ -118,6 +119,15 @@ del todo desde el panel ("Limpiar registro"). `severity` (alta/media/baja) nunca se etiqueta a mano: backend y errores de render de React = alta, errores JS sueltos = media, promesas rechazadas sin manejar = baja. +- `vault_entries` (desde v2.7.0): `password_enc`/`notes_enc` guardan el resultado de `encrypt()` + (AES-256-GCM, `backend/src/utils/crypto.js`, mismo mecanismo ya usado para la clave SMTP) — + nunca texto plano. Privado por usuario, sin excepción (ni superadmin ve lo de otro). El acceso + al menú "Contraseñas" además pide un PIN de 4 dígitos (`users.vault_pin_hash`, bcrypt) — el PIN + es un candado de acceso, NO participa del cifrado (sería débil para eso). Cambiar/resetear el + PIN (incluso por correo, `vault_pin_reset_token`) nunca toca `vault_entries` — es un cambio de + candado, no una recuperación de datos. Cada vez que se revela/crea/edita/elimina una entrada, o + se desbloquea el candado, queda en `audit_log`. Compartir una entrada con otro usuario queda + para una fase 2 (no implementado). ## Riesgos o decisiones diff --git a/CHANGELOG.md b/CHANGELOG.md index f5123a3..7a2e1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog - project.linkedcorp.net +## [2.7.0] - 2026-10-01 +### Added +- Nuevo menú "Contraseñas": repositorio personal de credenciales (nombre, enlace, usuario, + contraseña, notas), siempre privado — nadie más lo ve, ni un superadmin. +- Contraseña y notas cifradas en el servidor (AES-256-GCM, mismo mecanismo ya probado que usa la + clave SMTP) — nunca en texto plano en la base de datos. +- El menú pide un PIN de 4 dígitos para entrar, aparte de la clave de acceso normal al sistema + (que no se toca). El PIN es un candado de acceso, no la clave de cifrado. Se puede cambiar + desde el propio menú, o restablecer por correo si se olvida — restablecerlo nunca borra ni + afecta las contraseñas ya guardadas, solo cambia el candado. +- Generador de contraseñas al crear una entrada nueva. +- Queda registrado en el log de auditoría cada vez que se crea, edita, elimina o **revela** una + contraseña guardada, y cada desbloqueo del candado. +- Compartir una entrada con otro usuario queda para una próxima versión. + ## [2.6.1] - 2026-09-30 ### Fixed - **Bug real en el botón "Actualizar" (auto-actualización)**: el paso final reiniciaba backend y diff --git a/VERSION b/VERSION index 6a6a3d8..24ba9a3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.1 +2.7.0 diff --git a/backend/database/migrations/017_vault.sql b/backend/database/migrations/017_vault.sql new file mode 100644 index 0000000..873bb97 --- /dev/null +++ b/backend/database/migrations/017_vault.sql @@ -0,0 +1,24 @@ +-- Repositorio de credenciales privado por usuario ("Contraseñas"). Idempotente. +-- password_enc/notes_enc guardan el resultado de encrypt() (AES-256-GCM, backend/src/utils/crypto.js), +-- nunca texto plano. Privado por usuario -- nadie mas lo ve, ni siquiera un superadmin. +CREATE TABLE IF NOT EXISTS vault_entries ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + title VARCHAR(160) NOT NULL, + url VARCHAR(500) NULL, + username VARCHAR(160) NULL, + password_enc TEXT NULL, + notes_enc TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_vault_entries_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_entries_user ON vault_entries(user_id); + +-- PIN de acceso al modulo (candado, NO participa del cifrado -- 4 digitos seria debil para eso). +-- Independiente del password_hash de login: cambiarlo/resetearlo nunca toca vault_entries. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS vault_pin_hash VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS vault_pin_reset_token VARCHAR(128) NULL, + ADD COLUMN IF NOT EXISTS vault_pin_reset_token_expires DATETIME NULL; +CREATE INDEX IF NOT EXISTS idx_users_vault_pin_reset_token ON users(vault_pin_reset_token); diff --git a/backend/package.json b/backend/package.json index d72c553..cbed952 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.6.1", + "version": "2.7.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index a1fee32..feb4076 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -23,6 +23,7 @@ import searchRoutes from './modules/search/routes.js'; import updaterRoutes from './modules/updater/routes.js'; import errorLogsRoutes from './modules/errorLogs/routes.js'; import notesRoutes from './modules/notes/routes.js'; +import vaultRoutes from './modules/vault/routes.js'; export const app = express(); @@ -50,6 +51,7 @@ app.use('/api/search', searchRoutes); app.use('/api/updater', updaterRoutes); app.use('/api/error-logs', errorLogsRoutes); app.use('/api/notes', notesRoutes); +app.use('/api/vault', vaultRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/modules/vault/controller.js b/backend/src/modules/vault/controller.js new file mode 100644 index 0000000..ec7e99c --- /dev/null +++ b/backend/src/modules/vault/controller.js @@ -0,0 +1,51 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getStatus = asyncHandler(async (req, res) => { + ok(res, await service.getStatus(req.currentUser)); +}); + +export const postPin = asyncHandler(async (req, res) => { + await service.createPin(req.body.pin, req.currentUser); + ok(res, { created: true }); +}); + +export const postUnlock = asyncHandler(async (req, res) => { + ok(res, await service.unlock(req.body.pin, req.currentUser)); +}); + +export const postChangePin = asyncHandler(async (req, res) => { + await service.changePin(req.body.currentPin, req.body.newPin, req.currentUser); + ok(res, { changed: true }); +}); + +export const postForgotPin = asyncHandler(async (req, res) => { + await service.forgotPin(req.currentUser); + ok(res, { sent: true }); +}); + +export const postResetPin = asyncHandler(async (req, res) => { + await service.resetPin(req.body.token, req.body.newPin); + ok(res, { reset: true }); +}); + +export const getEntries = asyncHandler(async (req, res) => { + ok(res, await service.listEntries(req.currentUser)); +}); + +export const getEntry = asyncHandler(async (req, res) => { + ok(res, await service.getEntry(Number(req.params.id), req.currentUser)); +}); + +export const postEntry = asyncHandler(async (req, res) => { + ok(res, await service.createEntry(req.body, req.currentUser), 201); +}); + +export const putEntry = asyncHandler(async (req, res) => { + ok(res, await service.updateEntry(Number(req.params.id), req.body, req.currentUser)); +}); + +export const deleteEntry = asyncHandler(async (req, res) => { + await service.removeEntry(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/vault/middleware.js b/backend/src/modules/vault/middleware.js new file mode 100644 index 0000000..27e5051 --- /dev/null +++ b/backend/src/modules/vault/middleware.js @@ -0,0 +1,21 @@ +import jwt from 'jsonwebtoken'; +import { env } from '../../config/env.js'; +import { fail } from '../../utils/http.js'; + +// Candado de acceso a /api/vault/entries*: token corto y propio (scope:'vault'), NUNCA el JWT de +// login -- se pide recien despues de verificar el PIN (ver service.js#unlock). Va en un header +// propio para no mezclarse con el Authorization normal, que sigue intacto. +export function requireVaultUnlock(req, res, next) { + const token = req.headers['x-vault-token']; + if (!token) return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN'); + let payload; + try { + payload = jwt.verify(token, env.jwtSecret); + } catch { + return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN'); + } + if (payload.scope !== 'vault' || payload.sub !== req.currentUser.id) { + return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN'); + } + next(); +} diff --git a/backend/src/modules/vault/repository.js b/backend/src/modules/vault/repository.js new file mode 100644 index 0000000..9020cfe --- /dev/null +++ b/backend/src/modules/vault/repository.js @@ -0,0 +1,67 @@ +import { pool } from '../../config/db.js'; + +const LIST_SELECT = 'id, user_id, title, url, username, created_at, updated_at'; +const FULL_SELECT = `${LIST_SELECT}, password_enc, notes_enc`; + +export async function findByUser(userId) { + const [rows] = await pool.query( + `SELECT ${LIST_SELECT} FROM vault_entries WHERE user_id = ? ORDER BY title`, + [userId] + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]); + return rows[0] || null; +} + +export async function create(userId, { title, url, username, passwordEnc, notesEnc }) { + const [result] = await pool.query( + `INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc) + VALUES (?, ?, ?, ?, ?, ?)`, + [userId, title, url || null, username || null, passwordEnc || null, notesEnc || null] + ); + return findById(result.insertId); +} + +export async function update(id, { title, url, username, passwordEnc, notesEnc }) { + await pool.query( + `UPDATE vault_entries SET title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ? WHERE id = ?`, + [title, url || null, username || null, passwordEnc || null, notesEnc || null, id] + ); + return findById(id); +} + +export async function remove(id) { + await pool.query('DELETE FROM vault_entries WHERE id = ?', [id]); +} + +// --- PIN de acceso al modulo (en la tabla users, ver migracion 017) --- + +export async function getVaultPinHash(userId) { + const [rows] = await pool.query('SELECT vault_pin_hash FROM users WHERE id = ?', [userId]); + return rows[0]?.vault_pin_hash || null; +} + +export async function setVaultPinHash(userId, hash) { + await pool.query( + 'UPDATE users SET vault_pin_hash = ?, vault_pin_reset_token = NULL, vault_pin_reset_token_expires = NULL WHERE id = ?', + [hash, userId] + ); +} + +export async function setPinResetToken(userId, token) { + await pool.query( + 'UPDATE users SET vault_pin_reset_token = ?, vault_pin_reset_token_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?', + [token, userId] + ); +} + +export async function findByPinResetToken(token) { + const [rows] = await pool.query( + 'SELECT id FROM users WHERE vault_pin_reset_token = ? AND vault_pin_reset_token_expires > NOW()', + [token] + ); + return rows[0] || null; +} diff --git a/backend/src/modules/vault/routes.js b/backend/src/modules/vault/routes.js new file mode 100644 index 0000000..83835e8 --- /dev/null +++ b/backend/src/modules/vault/routes.js @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { requireAuth } from '../../middleware/auth.js'; +import { requireVaultUnlock } from './middleware.js'; +import { + getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin, + getEntries, getEntry, postEntry, putEntry, deleteEntry, +} from './controller.js'; + +const router = Router(); + +// PIN de 4 digitos: 10,000 combinaciones -- sin este limiter seria trivial de forzar. +const unlockLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 8, standardHeaders: true, legacyHeaders: false }); + +// Publico, como auth/reset-password: el link del correo puede abrirse sin sesion activa. +router.post('/reset-pin', unlockLimiter, postResetPin); + +router.use(requireAuth); +router.get('/status', getStatus); +router.post('/pin', postPin); +router.post('/unlock', unlockLimiter, postUnlock); +router.post('/change-pin', unlockLimiter, postChangePin); +router.post('/forgot-pin', unlockLimiter, postForgotPin); + +router.get('/entries', requireVaultUnlock, getEntries); +router.get('/entries/:id', requireVaultUnlock, getEntry); +router.post('/entries', requireVaultUnlock, postEntry); +router.put('/entries/:id', requireVaultUnlock, putEntry); +router.delete('/entries/:id', requireVaultUnlock, deleteEntry); + +export default router; diff --git a/backend/src/modules/vault/service.js b/backend/src/modules/vault/service.js new file mode 100644 index 0000000..b180130 --- /dev/null +++ b/backend/src/modules/vault/service.js @@ -0,0 +1,144 @@ +import crypto from 'node:crypto'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import * as repo from './repository.js'; +import { env } from '../../config/env.js'; +import { encrypt, decrypt } from '../../utils/crypto.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { sendMail } from '../../utils/mailer.js'; + +const PIN_RE = /^\d{4}$/; +const VAULT_TOKEN_EXPIRES = '30m'; + +function newToken() { + return crypto.randomBytes(32).toString('hex'); +} + +async function getOwnEntryOr404(id, currentUser) { + const entry = await repo.findById(id); + if (!entry || entry.user_id !== currentUser.id) throw new HttpError(404, 'No encontrado'); + return entry; +} + +function stripSensitive(entry) { + const { password_enc, notes_enc, ...rest } = entry; + return rest; +} + +function decryptEntry(entry) { + return { + ...stripSensitive(entry), + password: entry.password_enc ? decrypt(entry.password_enc) : '', + notes: entry.notes_enc ? decrypt(entry.notes_enc) : '', + }; +} + +export async function listEntries(currentUser) { + const rows = await repo.findByUser(currentUser.id); + return rows; // sin password/notes -- la lista nunca trae datos sensibles +} + +// "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita. +export async function getEntry(id, currentUser) { + const entry = await getOwnEntryOr404(id, currentUser); + await logAudit({ userId: currentUser.id, action: 'view', entityType: 'vault_entry', entityId: id }); + return decryptEntry(entry); +} + +export async function createEntry(payload, currentUser) { + if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio'); + const entry = await repo.create(currentUser.id, { + title: payload.title.trim(), + url: payload.url?.trim(), + username: payload.username?.trim(), + passwordEnc: payload.password ? encrypt(payload.password) : null, + notesEnc: payload.notes ? encrypt(payload.notes) : null, + }); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id }); + return stripSensitive(entry); +} + +export async function updateEntry(id, payload, currentUser) { + await getOwnEntryOr404(id, currentUser); + if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio'); + const updated = await repo.update(id, { + title: payload.title.trim(), + url: payload.url?.trim(), + username: payload.username?.trim(), + passwordEnc: payload.password ? encrypt(payload.password) : null, + notesEnc: payload.notes ? encrypt(payload.notes) : null, + }); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id }); + return stripSensitive(updated); +} + +export async function removeEntry(id, currentUser) { + await getOwnEntryOr404(id, currentUser); + await repo.remove(id); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_entry', entityId: id }); +} + +// --- PIN --- + +export async function getStatus(currentUser) { + const hash = await repo.getVaultPinHash(currentUser.id); + return { hasPinSet: !!hash }; +} + +export async function createPin(pin, currentUser) { + if (!PIN_RE.test(pin)) throw new HttpError(400, 'El PIN debe ser de 4 numeros'); + const existing = await repo.getVaultPinHash(currentUser.id); + if (existing) throw new HttpError(400, 'Ya tenes un PIN creado -- usa "cambiar PIN" en su lugar'); + const hash = await bcrypt.hash(pin, 12); + await repo.setVaultPinHash(currentUser.id, hash); + await logAudit({ userId: currentUser.id, action: 'create_pin', entityType: 'vault_pin', entityId: currentUser.id }); +} + +export async function unlock(pin, currentUser) { + if (!PIN_RE.test(pin)) throw new HttpError(400, 'PIN invalido'); + const hash = await repo.getVaultPinHash(currentUser.id); + if (!hash) throw new HttpError(400, 'Todavia no creaste un PIN'); + const ok = await bcrypt.compare(pin, hash); + if (!ok) throw new HttpError(403, 'PIN incorrecto'); + const vaultToken = jwt.sign({ sub: currentUser.id, scope: 'vault' }, env.jwtSecret, { expiresIn: VAULT_TOKEN_EXPIRES }); + await logAudit({ userId: currentUser.id, action: 'unlock', entityType: 'vault_pin', entityId: currentUser.id }); + return { vaultToken }; +} + +export async function changePin(currentPin, newPin, currentUser) { + if (!PIN_RE.test(newPin)) throw new HttpError(400, 'El PIN nuevo debe ser de 4 numeros'); + const hash = await repo.getVaultPinHash(currentUser.id); + if (!hash) throw new HttpError(400, 'Todavia no creaste un PIN'); + const ok = await bcrypt.compare(currentPin, hash); + if (!ok) throw new HttpError(403, 'PIN actual incorrecto'); + const newHash = await bcrypt.hash(newPin, 12); + await repo.setVaultPinHash(currentUser.id, newHash); + await logAudit({ userId: currentUser.id, action: 'change_pin', entityType: 'vault_pin', entityId: currentUser.id }); +} + +// Solo cambia el PIN -- nunca toca vault_entries. No es "recuperar datos", es resetear el candado. +export async function forgotPin(currentUser) { + const token = newToken(); + await repo.setPinResetToken(currentUser.id, token); + const resetUrl = `${env.corsOrigin}/restablecer-pin?token=${token}`; + await sendMail({ + to: currentUser.email, + subject: 'Restablecer PIN de Contraseñas — project.linkedcorp.net', + html: `

Hola ${currentUser.name},

+

Pediste restablecer el PIN de acceso a tu repositorio de Contraseñas. Esto solo cambia + el PIN -- tus contraseñas guardadas no se tocan ni se pierden.

+

Elegi un PIN nuevo en este enlace (vence en 1 hora):

+

${resetUrl}

+

Si no pediste esto, ignora el correo.

`, + }); +} + +export async function resetPin(token, newPin) { + if (!PIN_RE.test(newPin)) throw new HttpError(400, 'El PIN debe ser de 4 numeros'); + const user = await repo.findByPinResetToken(token); + if (!user) throw new HttpError(400, 'El enlace para restablecer el PIN es invalido o expiro'); + const hash = await bcrypt.hash(newPin, 12); + await repo.setVaultPinHash(user.id, hash); + await logAudit({ userId: user.id, action: 'reset_pin', entityType: 'vault_pin', entityId: user.id }); +} diff --git a/frontend/package.json b/frontend/package.json index 75d4dfb..a589798 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.6.1", + "version": "2.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 8c13615..91df35f 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -20,6 +20,8 @@ import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx'; import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx'; import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; import NotasPage from '../pages/Notas/NotasPage.jsx'; +import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx'; +import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx'; export default function App() { return ( @@ -34,6 +36,7 @@ export default function App() { } /> } /> } /> + } /> } /> }> } /> @@ -41,6 +44,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index 3d96b02..8fba9b7 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote } from 'lucide-react'; +import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; import { useAuth } from '../context/auth-context.jsx'; import { useBranding } from '../context/branding-context.jsx'; @@ -13,6 +13,7 @@ const ITEMS = [ { to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' }, { to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' }, { to: '/notas', icon: StickyNote, labelKey: 'nav_notes' }, + { to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' }, { to: '/organizacion', icon: Building2, labelKey: 'nav_organization' }, { to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true }, ]; diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 8441c24..c5bf5ad 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -185,6 +185,33 @@ export const messages = { nav_recurring_tasks: 'Tareas recurrentes', nav_notes: 'Notas', + nav_passwords: 'Contraseñas', + vault_pin_label: 'PIN (4 números)', + vault_pin_confirm_label: 'Confirmar PIN', + vault_pin_current_label: 'PIN actual', + vault_pin_invalid: 'El PIN debe ser de 4 números', + vault_pin_mismatch: 'Los PIN no coinciden', + vault_create_pin_title: 'Crear PIN de acceso', + vault_create_pin_hint: 'Este PIN protege el acceso a tus contraseñas guardadas. Vas a necesitarlo cada vez que entres a esta sección.', + vault_create_pin_submit: 'Crear PIN y entrar', + vault_enter_pin_title: 'Ingresa tu PIN', + vault_unlock: 'Desbloquear', + vault_forgot_pin: '¿Olvidaste tu PIN?', + vault_forgot_pin_sent: 'Te enviamos un correo para restablecer tu PIN. Tus contraseñas guardadas no se ven afectadas.', + vault_change_pin: 'Cambiar PIN', + vault_reset_pin_title: 'Restablecer PIN', + vault_reset_pin_submit: 'Guardar PIN nuevo', + vault_reset_pin_success: 'Tu PIN fue actualizado. Tus contraseñas guardadas siguen intactas.', + vault_new_entry: 'Nueva credencial', + vault_field_title: 'Nombre', + vault_field_url: 'Enlace', + vault_field_username: 'Usuario', + vault_field_password: 'Contraseña', + vault_field_notes: 'Notas', + vault_reveal: 'Revelar', + vault_copy: 'Copiar', + vault_copied: 'Copiado al portapapeles', + vault_generate: 'Generar', notes_title: 'Notas', notes_placeholder: 'Escribe un apunte rápido...', notes_add: 'Agregar', @@ -484,6 +511,33 @@ export const messages = { nav_recurring_tasks: 'Recurring tasks', nav_notes: 'Notes', + nav_passwords: 'Passwords', + vault_pin_label: 'PIN (4 digits)', + vault_pin_confirm_label: 'Confirm PIN', + vault_pin_current_label: 'Current PIN', + vault_pin_invalid: 'The PIN must be 4 digits', + vault_pin_mismatch: 'The PINs don\'t match', + vault_create_pin_title: 'Create access PIN', + vault_create_pin_hint: 'This PIN protects access to your saved passwords. You\'ll need it every time you enter this section.', + vault_create_pin_submit: 'Create PIN and enter', + vault_enter_pin_title: 'Enter your PIN', + vault_unlock: 'Unlock', + vault_forgot_pin: 'Forgot your PIN?', + vault_forgot_pin_sent: 'We sent you an email to reset your PIN. Your saved passwords are not affected.', + vault_change_pin: 'Change PIN', + vault_reset_pin_title: 'Reset PIN', + vault_reset_pin_submit: 'Save new PIN', + vault_reset_pin_success: 'Your PIN was updated. Your saved passwords are still intact.', + vault_new_entry: 'New credential', + vault_field_title: 'Name', + vault_field_url: 'Link', + vault_field_username: 'Username', + vault_field_password: 'Password', + vault_field_notes: 'Notes', + vault_reveal: 'Reveal', + vault_copy: 'Copy', + vault_copied: 'Copied to clipboard', + vault_generate: 'Generate', notes_title: 'Notes', notes_placeholder: 'Write a quick note...', notes_add: 'Add', diff --git a/frontend/src/pages/Contrasenas/ContrasenasPage.jsx b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx new file mode 100644 index 0000000..f34df4d --- /dev/null +++ b/frontend/src/pages/Contrasenas/ContrasenasPage.jsx @@ -0,0 +1,333 @@ +import { useEffect, useState } from 'react'; +import { Plus, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert } from 'lucide-react'; +import { useUi } from '../../context/ui-context.jsx'; +import { useAction } from '../../hooks/useAsync.js'; +import { + getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin, + listVaultEntries, getVaultEntry, createVaultEntry, updateVaultEntry, deleteVaultEntry, +} from '../../services/vault.js'; +import { ListView } from '../../components/ListView.jsx'; +import { Button } from '../../components/Button.jsx'; +import { Field } from '../../components/Field.jsx'; +import { PasswordInput } from '../../components/PasswordInput.jsx'; +import { DetailPopup } from '../../components/DetailPopup.jsx'; + +const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '' }; + +function randomPassword(length = 20) { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'; + const bytes = crypto.getRandomValues(new Uint32Array(length)); + return Array.from(bytes, (b) => chars[b % chars.length]).join(''); +} + +export default function ContrasenasPage() { + const { t, pushToast, openDialog } = useUi(); + const runAction = useAction(); + + const [locked, setLocked] = useState(true); + const [checking, setChecking] = useState(true); + const [hasPinSet, setHasPinSet] = useState(null); + const [entries, setEntries] = useState(null); + + const [pin, setPin] = useState(''); + const [pinConfirm, setPinConfirm] = useState(''); + const [pinError, setPinError] = useState(''); + const [busy, setBusy] = useState(false); + + const [selected, setSelected] = useState(null); // entrada abierta en el popup + const [editing, setEditing] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [revealedPassword, setRevealedPassword] = useState(null); + const [passwordVisible, setPasswordVisible] = useState(false); + const [creating, setCreating] = useState(false); + + const [changingPin, setChangingPin] = useState(false); + const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' }); + const [pinChangeError, setPinChangeError] = useState(''); + + async function loadEntries() { + const data = await listVaultEntries(); + setEntries(data); + setLocked(false); + } + + async function checkAccess() { + setChecking(true); + try { + await loadEntries(); + } catch { + setLocked(true); + try { + const status = await getVaultStatus(); + setHasPinSet(status.hasPinSet); + } catch { /* ignora, se reintenta al enviar el pin */ } + } finally { + setChecking(false); + } + } + + useEffect(() => { checkAccess(); }, []); + + async function submitPin() { + setPinError(''); + if (!/^\d{4}$/.test(pin)) { setPinError(t('vault_pin_invalid')); return; } + setBusy(true); + try { + if (!hasPinSet) { + if (pin !== pinConfirm) { setPinError(t('vault_pin_mismatch')); setBusy(false); return; } + await createVaultPin(pin); + } + await unlockVault(pin); + setPin(''); setPinConfirm(''); + await loadEntries(); + } catch (err) { + setPinError(err.message); + } finally { + setBusy(false); + } + } + + async function handleForgotPin() { + await runAction(async () => { + await forgotVaultPin(); + pushToast({ tone: 'success', message: t('vault_forgot_pin_sent') }); + }); + } + + async function submitChangePin() { + setPinChangeError(''); + if (!/^\d{4}$/.test(pinChangeForm.next)) { setPinChangeError(t('vault_pin_invalid')); return; } + if (pinChangeForm.next !== pinChangeForm.next2) { setPinChangeError(t('vault_pin_mismatch')); return; } + try { + await changeVaultPin(pinChangeForm.current, pinChangeForm.next); + setChangingPin(false); + setPinChangeForm({ current: '', next: '', next2: '' }); + pushToast({ tone: 'success', message: t('common_saved') }); + } catch (err) { + setPinChangeError(err.message); + } + } + + function openEntry(entry) { + setSelected(entry); + setForm({ title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '' }); + setRevealedPassword(null); + setPasswordVisible(false); + setEditing(false); + setCreating(false); + } + + function openCreate() { + setSelected(null); + setForm(EMPTY_FORM); + setRevealedPassword(null); + setPasswordVisible(false); + setEditing(true); + setCreating(true); + } + + function closePopup() { + setSelected(null); + setCreating(false); + setEditing(false); + } + + async function reveal() { + await runAction(async () => { + const full = await getVaultEntry(selected.id); + setRevealedPassword(full.password); + setForm((f) => ({ ...f, notes: full.notes || '' })); + setPasswordVisible(true); + }); + } + + function startEdit() { + setForm((f) => ({ ...f, password: revealedPassword || '' })); + setEditing(true); + } + + function cancelEdit() { + if (creating) { closePopup(); return; } + setEditing(false); + openEntry(selected); + } + + async function save() { + if (!form.title.trim()) return; + await runAction(async () => { + if (creating) { + await createVaultEntry(form); + pushToast({ tone: 'success', message: t('common_saved') }); + } else { + await updateVaultEntry(selected.id, form); + pushToast({ tone: 'success', message: t('common_saved') }); + } + closePopup(); + await loadEntries(); + }); + } + + function confirmDelete(entry) { + openDialog({ + tone: 'warning', + title: t('common_confirm_delete_title'), + message: t('common_confirm_delete_msg'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_delete'), variant: 'danger', + onClick: () => runAction(async () => { + await deleteVaultEntry(entry.id); + await loadEntries(); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + + function copyToClipboard(value) { + navigator.clipboard?.writeText(value); + pushToast({ tone: 'success', message: t('vault_copied') }); + } + + const columns = [ + { key: 'title', label: t('vault_field_title') }, + { key: 'url', label: t('vault_field_url'), render: (r) => r.url ? e.stopPropagation()} className="text-link">{r.url} : '—' }, + { key: 'username', label: t('vault_field_username'), render: (r) => r.username || '—' }, + { key: 'updated_at', label: t('project_updated_at'), render: (r) => String(r.updated_at).slice(0, 16) }, + ]; + + if (checking) return
{t('common_loading')}
; + + if (locked) { + return ( +
+
+ +

{hasPinSet ? t('vault_enter_pin_title') : t('vault_create_pin_title')}

+
+ {!hasPinSet &&

{t('vault_create_pin_hint')}

} + + setPin(e.target.value.replace(/\D/g, ''))} + onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }} + /> + + {!hasPinSet && ( + + setPinConfirm(e.target.value.replace(/\D/g, ''))} + onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }} + /> + + )} + {pinError &&
{pinError}
} + + {hasPinSet && ( + + )} +
+ ); + } + + return ( +
+
+

{t('nav_passwords')}

+
+ + +
+ + r.id} + onRowClick={openEntry} + rowActions={(r) => [ + { label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) }, + ]} + /> + + + + setForm((f) => ({ ...f, title: e.target.value }))} /> + + + setForm((f) => ({ ...f, url: e.target.value }))} /> + + + setForm((f) => ({ ...f, username: e.target.value }))} /> + + + + {editing ? ( +
+
+ setForm((f) => ({ ...f, password: e.target.value }))} /> +
+ +
+ ) : ( +
+ + {!revealedPassword ? ( + + ) : ( + <> + + + + )} +
+ )} +
+ + +