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:
Milton Gonzalez
2026-10-01 12:12:54 -05:00
parent f607455668
commit 0be0712ff0
20 changed files with 879 additions and 10 deletions
+4
View File
@@ -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() {
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route index element={<DashboardPage />} />
@@ -41,6 +44,7 @@ export default function App() {
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
<Route path="notas" element={<NotasPage />} />
<Route path="contrasenas" element={<ContrasenasPage />} />
<Route path="organizacion" element={<OrganizacionPage />} />
<Route path="configuracion" element={<ConfiguracionPage />} />
</Route>
+2 -1
View File
@@ -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 },
];
+54
View File
@@ -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',
@@ -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>
);
}
+6 -6
View File
@@ -9,8 +9,8 @@ function getToken() {
return localStorage.getItem('plc_token');
}
async function request(method, path, { body, isForm } = {}) {
const headers = {};
async function request(method, path, { body, isForm, extraHeaders } = {}) {
const headers = { ...extraHeaders };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
if (!isForm && body !== undefined) headers['Content-Type'] = 'application/json';
@@ -38,11 +38,11 @@ async function request(method, path, { body, isForm } = {}) {
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, { body }),
put: (path, body) => request('PUT', path, { body }),
get: (path, extraHeaders) => request('GET', path, { extraHeaders }),
post: (path, body, extraHeaders) => request('POST', path, { body, extraHeaders }),
put: (path, body, extraHeaders) => request('PUT', path, { body, extraHeaders }),
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 }),
putForm: (path, formData) => request('PUT', path, { body: formData, isForm: true }),
setToken: (token) => {
+33
View File
@@ -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());
+7
View File
@@ -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 a { color: rgb(var(--muted)); text-decoration: none; }
.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) ---
Al elegir un item de un listado, la lista queda inactiva detras del overlay y el popup