feat: version 2.7.0 -- menu Contraseñas (repositorio de credenciales privado)
Nuevo modulo backend/src/modules/vault/: contraseña y notas cifradas en el servidor (AES-256-GCM, mismo mecanismo ya usado para la clave SMTP). Acceso al menu protegido con un PIN de 4 digitos independiente del login (token corto propio, header X-Vault-Token, nunca el JWT de sesion). El PIN no participa del cifrado -- cambiarlo o restablecerlo por correo nunca afecta las contraseñas ya guardadas. Privado por usuario, sin excepcion. Cada accion sensible (crear/editar/eliminar/ revelar/desbloquear) queda en audit_log. Compartir con otro usuario queda para una proxima version. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@
|
|||||||
| `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) |
|
| `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 |
|
| `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" |
|
| `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
|
## Relaciones clave
|
||||||
|
|
||||||
@@ -118,6 +119,15 @@
|
|||||||
del todo desde el panel ("Limpiar registro"). `severity` (alta/media/baja) nunca se etiqueta a
|
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
|
mano: backend y errores de render de React = alta, errores JS sueltos = media, promesas
|
||||||
rechazadas sin manejar = baja.
|
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
|
## Riesgos o decisiones
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.6.1] - 2026-09-30
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Bug real en el botón "Actualizar" (auto-actualización)**: el paso final reiniciaba backend y
|
- **Bug real en el botón "Actualizar" (auto-actualización)**: el paso final reiniciaba backend y
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.6.1",
|
"version": "2.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import searchRoutes from './modules/search/routes.js';
|
|||||||
import updaterRoutes from './modules/updater/routes.js';
|
import updaterRoutes from './modules/updater/routes.js';
|
||||||
import errorLogsRoutes from './modules/errorLogs/routes.js';
|
import errorLogsRoutes from './modules/errorLogs/routes.js';
|
||||||
import notesRoutes from './modules/notes/routes.js';
|
import notesRoutes from './modules/notes/routes.js';
|
||||||
|
import vaultRoutes from './modules/vault/routes.js';
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ app.use('/api/search', searchRoutes);
|
|||||||
app.use('/api/updater', updaterRoutes);
|
app.use('/api/updater', updaterRoutes);
|
||||||
app.use('/api/error-logs', errorLogsRoutes);
|
app.use('/api/error-logs', errorLogsRoutes);
|
||||||
app.use('/api/notes', notesRoutes);
|
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
|
// 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
|
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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: `<p>Hola ${currentUser.name},</p>
|
||||||
|
<p>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.</p>
|
||||||
|
<p>Elegi un PIN nuevo en este enlace (vence en 1 hora):</p>
|
||||||
|
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
||||||
|
<p>Si no pediste esto, ignora el correo.</p>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.6.1",
|
"version": "2.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx';
|
|||||||
import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
|
import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx';
|
||||||
import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
|
import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx';
|
||||||
import NotasPage from '../pages/Notas/NotasPage.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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +36,7 @@ export default function App() {
|
|||||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||||
|
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
|
||||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
@@ -41,6 +44,7 @@ export default function App() {
|
|||||||
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
||||||
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
||||||
<Route path="notas" element={<NotasPage />} />
|
<Route path="notas" element={<NotasPage />} />
|
||||||
|
<Route path="contrasenas" element={<ContrasenasPage />} />
|
||||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
<Route path="organizacion" element={<OrganizacionPage />} />
|
||||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
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 { useUi } from '../context/ui-context.jsx';
|
||||||
import { useAuth } from '../context/auth-context.jsx';
|
import { useAuth } from '../context/auth-context.jsx';
|
||||||
import { useBranding } from '../context/branding-context.jsx';
|
import { useBranding } from '../context/branding-context.jsx';
|
||||||
@@ -13,6 +13,7 @@ const ITEMS = [
|
|||||||
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
|
{ to: '/proyectos', icon: FolderKanban, labelKey: 'nav_projects' },
|
||||||
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
{ to: '/tareas-recurrentes', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
||||||
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
|
{ to: '/notas', icon: StickyNote, labelKey: 'nav_notes' },
|
||||||
|
{ to: '/contrasenas', icon: KeyRound, labelKey: 'nav_passwords' },
|
||||||
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
|
{ to: '/organizacion', icon: Building2, labelKey: 'nav_organization' },
|
||||||
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
|
{ to: '/configuracion', icon: Settings, labelKey: 'nav_config', superadminOnly: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -185,6 +185,33 @@ export const messages = {
|
|||||||
|
|
||||||
nav_recurring_tasks: 'Tareas recurrentes',
|
nav_recurring_tasks: 'Tareas recurrentes',
|
||||||
nav_notes: 'Notas',
|
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_title: 'Notas',
|
||||||
notes_placeholder: 'Escribe un apunte rápido...',
|
notes_placeholder: 'Escribe un apunte rápido...',
|
||||||
notes_add: 'Agregar',
|
notes_add: 'Agregar',
|
||||||
@@ -484,6 +511,33 @@ export const messages = {
|
|||||||
|
|
||||||
nav_recurring_tasks: 'Recurring tasks',
|
nav_recurring_tasks: 'Recurring tasks',
|
||||||
nav_notes: 'Notes',
|
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_title: 'Notes',
|
||||||
notes_placeholder: 'Write a quick note...',
|
notes_placeholder: 'Write a quick note...',
|
||||||
notes_add: 'Add',
|
notes_add: 'Add',
|
||||||
|
|||||||
@@ -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 ? <a href={r.url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} className="text-link">{r.url}</a> : '—' },
|
||||||
|
{ 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 <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 360, margin: '60px auto' }} className="panel-card">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<ShieldAlert size={20} />
|
||||||
|
<h3 style={{ margin: 0 }}>{hasPinSet ? t('vault_enter_pin_title') : t('vault_create_pin_title')}</h3>
|
||||||
|
</div>
|
||||||
|
{!hasPinSet && <p className="text-muted" style={{ fontSize: '.8rem' }}>{t('vault_create_pin_hint')}</p>}
|
||||||
|
<Field label={t('vault_pin_label')}>
|
||||||
|
<input
|
||||||
|
className="input" type="password" inputMode="numeric" maxLength={4} autoFocus
|
||||||
|
value={pin} onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{!hasPinSet && (
|
||||||
|
<Field label={t('vault_pin_confirm_label')}>
|
||||||
|
<input
|
||||||
|
className="input" type="password" inputMode="numeric" maxLength={4}
|
||||||
|
value={pinConfirm} onChange={(e) => setPinConfirm(e.target.value.replace(/\D/g, ''))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{pinError && <div className="login-error">{pinError}</div>}
|
||||||
|
<Button variant="accent" onClick={submitPin} disabled={busy} style={{ width: '100%', marginTop: 8 }}>
|
||||||
|
{hasPinSet ? t('vault_unlock') : t('vault_create_pin_submit')}
|
||||||
|
</Button>
|
||||||
|
{hasPinSet && (
|
||||||
|
<button type="button" className="link-button" style={{ marginTop: 10 }} onClick={handleForgotPin}>
|
||||||
|
{t('vault_forgot_pin')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="toolbar">
|
||||||
|
<h2 className="page-title mt-0" style={{ marginBottom: 0 }}>{t('nav_passwords')}</h2>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button>
|
||||||
|
<Button variant="accent" icon={Plus} onClick={openCreate}>{t('vault_new_entry')}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListView
|
||||||
|
columns={columns}
|
||||||
|
rows={entries}
|
||||||
|
getRowKey={(r) => r.id}
|
||||||
|
onRowClick={openEntry}
|
||||||
|
rowActions={(r) => [
|
||||||
|
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(r) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailPopup
|
||||||
|
open={creating || !!selected}
|
||||||
|
title={creating ? t('vault_new_entry') : (selected?.title || '')}
|
||||||
|
onClose={closePopup}
|
||||||
|
width={480}
|
||||||
|
height={560}
|
||||||
|
>
|
||||||
|
<Field label={t('vault_field_title')}>
|
||||||
|
<input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('vault_field_url')}>
|
||||||
|
<input className="input" value={form.url} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('vault_field_username')}>
|
||||||
|
<input className="input" value={form.username} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={t('vault_field_password')}>
|
||||||
|
{editing ? (
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<Button variant="standard" onClick={() => setForm((f) => ({ ...f, password: randomPassword() }))}>{t('vault_generate')}</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input className="input" type={passwordVisible ? 'text' : 'password'} value={passwordVisible ? (revealedPassword || '') : '••••••••'} disabled readOnly style={{ flex: 1 }} />
|
||||||
|
{!revealedPassword ? (
|
||||||
|
<button className="icon-btn" title={t('vault_reveal')} onClick={reveal}><Eye size={16} /></button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="icon-btn" onClick={() => setPasswordVisible((v) => !v)}>{passwordVisible ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||||
|
<button className="icon-btn" title={t('vault_copy')} onClick={() => copyToClipboard(revealedPassword)}><Copy size={16} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={t('vault_field_notes')}>
|
||||||
|
<textarea className="textarea" rows={3} disabled={!editing} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</DetailPopup>
|
||||||
|
|
||||||
|
<DetailPopup open={changingPin} title={t('vault_change_pin')} onClose={() => setChangingPin(false)} width={360} height={360}>
|
||||||
|
<Field label={t('vault_pin_current_label')}>
|
||||||
|
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.current} onChange={(e) => setPinChangeForm((f) => ({ ...f, current: e.target.value.replace(/\D/g, '') }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('vault_pin_label')}>
|
||||||
|
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.next} onChange={(e) => setPinChangeForm((f) => ({ ...f, next: e.target.value.replace(/\D/g, '') }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('vault_pin_confirm_label')}>
|
||||||
|
<input className="input" type="password" inputMode="numeric" maxLength={4} value={pinChangeForm.next2} onChange={(e) => setPinChangeForm((f) => ({ ...f, next2: e.target.value.replace(/\D/g, '') }))} />
|
||||||
|
</Field>
|
||||||
|
{pinChangeError && <div className="login-error">{pinChangeError}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
|
<Button variant="accent" onClick={submitChangePin}>{t('common_save')}</Button>
|
||||||
|
<Button variant="standard" onClick={() => setChangingPin(false)}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</DetailPopup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { KeyRound } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useBranding } from '../../context/branding-context.jsx';
|
||||||
|
import { resetVaultPin } from '../../services/vault.js';
|
||||||
|
import { Field } from '../../components/Field.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||||
|
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||||
|
|
||||||
|
export default function ResetVaultPinPage() {
|
||||||
|
const { t, theme } = useUi();
|
||||||
|
const branding = useBranding();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get('token') || '';
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (!/^\d{4}$/.test(pin)) { setError(t('vault_pin_invalid')); return; }
|
||||||
|
if (pin !== confirm) { setError(t('vault_pin_mismatch')); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await resetVaultPin(token, pin);
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => navigate('/login', { replace: true }), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || t('common_error'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-shell">
|
||||||
|
<form className="login-card" onSubmit={handleSubmit}>
|
||||||
|
<div className="login-logo">
|
||||||
|
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||||
|
</div>
|
||||||
|
<div className="login-title">{t('vault_reset_pin_title')}</div>
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
{success ? (
|
||||||
|
<div className="login-info">{t('vault_reset_pin_success')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field label={t('vault_pin_label')}>
|
||||||
|
<input className="input" type="password" inputMode="numeric" maxLength={4} autoFocus
|
||||||
|
value={pin} onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))} required />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('vault_pin_confirm_label')}>
|
||||||
|
<input className="input" type="password" inputMode="numeric" maxLength={4}
|
||||||
|
value={confirm} onChange={(e) => setConfirm(e.target.value.replace(/\D/g, ''))} required />
|
||||||
|
</Field>
|
||||||
|
<Button variant="accent" icon={KeyRound} type="submit" disabled={loading || !token} style={{ width: '100%', justifyContent: 'center' }}>
|
||||||
|
{t('vault_reset_pin_submit')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="login-links">
|
||||||
|
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ function getToken() {
|
|||||||
return localStorage.getItem('plc_token');
|
return localStorage.getItem('plc_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(method, path, { body, isForm } = {}) {
|
async function request(method, path, { body, isForm, extraHeaders } = {}) {
|
||||||
const headers = {};
|
const headers = { ...extraHeaders };
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
if (!isForm && body !== undefined) headers['Content-Type'] = 'application/json';
|
if (!isForm && body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
@@ -38,11 +38,11 @@ async function request(method, path, { body, isForm } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: (path) => request('GET', path),
|
get: (path, extraHeaders) => request('GET', path, { extraHeaders }),
|
||||||
post: (path, body) => request('POST', path, { body }),
|
post: (path, body, extraHeaders) => request('POST', path, { body, extraHeaders }),
|
||||||
put: (path, body) => request('PUT', path, { body }),
|
put: (path, body, extraHeaders) => request('PUT', path, { body, extraHeaders }),
|
||||||
patch: (path, body) => request('PATCH', path, { body }),
|
patch: (path, body) => request('PATCH', path, { body }),
|
||||||
delete: (path) => request('DELETE', path),
|
delete: (path, extraHeaders) => request('DELETE', path, { extraHeaders }),
|
||||||
postForm: (path, formData) => request('POST', path, { body: formData, isForm: true }),
|
postForm: (path, formData) => request('POST', path, { body: formData, isForm: true }),
|
||||||
putForm: (path, formData) => request('PUT', path, { body: formData, isForm: true }),
|
putForm: (path, formData) => request('PUT', path, { body: formData, isForm: true }),
|
||||||
setToken: (token) => {
|
setToken: (token) => {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
const VAULT_TOKEN_KEY = 'plc_vault_token';
|
||||||
|
|
||||||
|
export function getVaultToken() {
|
||||||
|
return sessionStorage.getItem(VAULT_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
export function setVaultToken(token) {
|
||||||
|
if (token) sessionStorage.setItem(VAULT_TOKEN_KEY, token);
|
||||||
|
else sessionStorage.removeItem(VAULT_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultHeaders() {
|
||||||
|
const token = getVaultToken();
|
||||||
|
return token ? { 'X-Vault-Token': token } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVaultStatus = () => api.get('/vault/status');
|
||||||
|
export const createVaultPin = (pin) => api.post('/vault/pin', { pin });
|
||||||
|
export const unlockVault = async (pin) => {
|
||||||
|
const { vaultToken } = await api.post('/vault/unlock', { pin });
|
||||||
|
setVaultToken(vaultToken);
|
||||||
|
return vaultToken;
|
||||||
|
};
|
||||||
|
export const changeVaultPin = (currentPin, newPin) => api.post('/vault/change-pin', { currentPin, newPin });
|
||||||
|
export const forgotVaultPin = () => api.post('/vault/forgot-pin');
|
||||||
|
export const resetVaultPin = (token, newPin) => api.post('/vault/reset-pin', { token, newPin });
|
||||||
|
|
||||||
|
export const listVaultEntries = () => api.get('/vault/entries', vaultHeaders());
|
||||||
|
export const getVaultEntry = (id) => api.get(`/vault/entries/${id}`, 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 deleteVaultEntry = (id) => api.delete(`/vault/entries/${id}`, vaultHeaders());
|
||||||
@@ -348,6 +348,13 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
|||||||
.login-links { display: flex; justify-content: space-between; gap: 10px; margin-top: 14px; font-size: .76rem; flex-wrap: wrap; }
|
.login-links { display: flex; justify-content: space-between; gap: 10px; margin-top: 14px; font-size: .76rem; flex-wrap: wrap; }
|
||||||
.login-links a { color: rgb(var(--muted)); text-decoration: none; }
|
.login-links a { color: rgb(var(--muted)); text-decoration: none; }
|
||||||
.login-links a:hover { color: rgb(var(--accent)); text-decoration: underline; }
|
.login-links a:hover { color: rgb(var(--accent)); text-decoration: underline; }
|
||||||
|
.link-button {
|
||||||
|
background: none; border: none; padding: 0; cursor: pointer;
|
||||||
|
color: rgb(var(--muted)); font-size: .76rem; text-decoration: none;
|
||||||
|
}
|
||||||
|
.link-button:hover { color: rgb(var(--accent)); text-decoration: underline; }
|
||||||
|
.text-link { color: rgb(var(--accent)); text-decoration: none; }
|
||||||
|
.text-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* --- DetailPopup (ver DetailPopup.jsx) ---
|
/* --- DetailPopup (ver DetailPopup.jsx) ---
|
||||||
Al elegir un item de un listado, la lista queda inactiva detras del overlay y el popup
|
Al elegir un item de un listado, la lista queda inactiva detras del overlay y el popup
|
||||||
|
|||||||
Reference in New Issue
Block a user