feat: boton de mostrar/ocultar clave en todos los campos de contraseña

Componente reutilizable PasswordInput (icono de ojo) usado en login, registro,
restablecer clave, cambiar clave, crear usuario y la clave SMTP de
Configuracion, para que el usuario pueda verificar que la esta escribiendo
correctamente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-09 09:00:06 -05:00
parent e02ea9ad93
commit 869094c622
13 changed files with 74 additions and 13 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog - project.linkedcorp.net
## [1.4.0] - 2026-09-09
### Added
- Boton de revelar/ocultar clave (icono de ojo) en todos los campos de contraseña de la app:
login, registro, restablecer clave, cambiar clave (usuario logueado), crear usuario y la clave
SMTP en Configuración. Nuevo componente reutilizable `PasswordInput`.
### Fixed
- El fix de espacios en SMTP del v1.3.0 usaba `trim()`, que solo recorta los bordes. El caso real
en producción tenía el espacio a mitad del host (`"mail.linkedcorp." + " " + "net"`), asi que
seguia fallando tras el primer fix. Corregido para quitar cualquier espacio, no solo los bordes.
## [1.3.0] - 2026-09-06
### Added
- Configuración → Correo (SMTP): botón "Enviar prueba" con campo de destinatario. Ejecuta un envío
+1 -1
View File
@@ -1 +1 @@
1.3.0
1.4.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"type": "module",
"main": "src/server.js",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "1.3.0",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+4 -3
View File
@@ -6,6 +6,7 @@ import { Flyout, FlyoutItem, FlyoutSeparator } from '../components/Flyout.jsx';
import { DetailPopup } from '../components/DetailPopup.jsx';
import { Field } from '../components/Field.jsx';
import { Button } from '../components/Button.jsx';
import { PasswordInput } from '../components/PasswordInput.jsx';
import { useAuth } from '../context/auth-context.jsx';
import { useUi } from '../context/ui-context.jsx';
import { useAsync } from '../hooks/useAsync.js';
@@ -131,13 +132,13 @@ export default function AppShell() {
<DetailPopup open={changingPassword} title={t('change_password_title')} onClose={() => setChangingPassword(false)} width={420} height={380}>
{passwordError && <div className="login-error">{passwordError}</div>}
<Field label={t('change_password_current')}>
<input className="input" type="password" value={passwordForm.currentPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, currentPassword: e.target.value }))} autoFocus />
<PasswordInput value={passwordForm.currentPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, currentPassword: e.target.value }))} autoFocus />
</Field>
<Field label={t('reset_password_new')}>
<input className="input" type="password" value={passwordForm.newPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, newPassword: e.target.value }))} />
<PasswordInput value={passwordForm.newPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, newPassword: e.target.value }))} />
</Field>
<Field label={t('register_password_confirm')}>
<input className="input" type="password" value={passwordForm.confirmPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, confirmPassword: e.target.value }))} />
<PasswordInput value={passwordForm.confirmPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, confirmPassword: e.target.value }))} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={submitChangePassword}>{t('common_save')}</Button>
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
export function PasswordInput({ value, onChange, required, autoFocus, placeholder, id }) {
const { t } = useUi();
const [visible, setVisible] = useState(false);
return (
<div className="password-input">
<input
id={id}
className="input"
type={visible ? 'text' : 'password'}
value={value}
onChange={onChange}
required={required}
autoFocus={autoFocus}
placeholder={placeholder}
/>
<button
type="button"
className="icon-btn password-toggle"
onClick={() => setVisible((v) => !v)}
tabIndex={-1}
aria-label={visible ? t('password_hide') : t('password_show')}
title={visible ? t('password_hide') : t('password_show')}
>
{visible ? <EyeOff /> : <Eye />}
</button>
</div>
);
}
+4
View File
@@ -59,6 +59,8 @@ export const messages = {
smtp_test_send: 'Enviar prueba',
smtp_test_sending: 'Enviando...',
smtp_test_success: 'Correo de prueba enviado correctamente a',
password_show: 'Mostrar clave',
password_hide: 'Ocultar clave',
common_new: 'Nuevo',
common_edit: 'Editar',
common_save: 'Guardar',
@@ -242,6 +244,8 @@ export const messages = {
smtp_test_send: 'Send test',
smtp_test_sending: 'Sending...',
smtp_test_success: 'Test email sent successfully to',
password_show: 'Show password',
password_hide: 'Hide password',
common_new: 'New',
common_edit: 'Edit',
common_save: 'Save',
@@ -6,6 +6,7 @@ import { getSmtpConfig, updateSmtpConfig, testSmtpConfig } from '../../services/
import { Field, FieldRow } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
export default function SmtpSettingsPanel() {
const { t, pushToast } = useUi();
@@ -66,7 +67,7 @@ export default function SmtpSettingsPanel() {
</Field>
</FieldRow>
<Field label={t('smtp_pass')} hint={config.configured ? t('smtp_pass_configured_hint') : undefined}>
<input className="input" type="password" value={form.pass} onChange={(e) => setForm((f) => ({ ...f, pass: e.target.value }))} />
<PasswordInput value={form.pass} onChange={(e) => setForm((f) => ({ ...f, pass: e.target.value }))} />
</Field>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
+2 -1
View File
@@ -5,6 +5,7 @@ import { useAuth } from '../../context/auth-context.jsx';
import { useUi } from '../../context/ui-context.jsx';
import { Field } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
import logoDark from '../../assets/logoLinkedCorpTxt.png';
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
@@ -43,7 +44,7 @@ export default function LoginPage() {
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
</Field>
<Field label={t('login_password')}>
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} required />
</Field>
<Button variant="accent" icon={LogIn} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
{t('login_submit')}
+3 -2
View File
@@ -6,6 +6,7 @@ import { useUi } from '../../context/ui-context.jsx';
import { register } from '../../services/auth.js';
import { Field } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
import logoDark from '../../assets/logoLinkedCorpTxt.png';
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
@@ -61,10 +62,10 @@ export default function RegisterPage() {
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</Field>
<Field label={t('login_password')}>
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} required />
</Field>
<Field label={t('register_password_confirm')}>
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
<PasswordInput value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</Field>
<Button variant="accent" icon={UserPlus} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
{t('register_submit')}
@@ -5,6 +5,7 @@ import { useUi } from '../../context/ui-context.jsx';
import { resetPassword } from '../../services/auth.js';
import { Field } from '../../components/Field.jsx';
import { Button } from '../../components/Button.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
import logoDark from '../../assets/logoLinkedCorpTxt.png';
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
@@ -49,10 +50,10 @@ export default function ResetPasswordPage() {
) : (
<>
<Field label={t('reset_password_new')}>
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoFocus />
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} required autoFocus />
</Field>
<Field label={t('register_password_confirm')}>
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
<PasswordInput value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</Field>
<Button variant="accent" icon={KeyRound} type="submit" disabled={loading || !token} style={{ width: '100%', justifyContent: 'center' }}>
{t('reset_password_submit')}
@@ -12,6 +12,7 @@ import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { Badge } from '../../components/Badge.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { PasswordInput } from '../../components/PasswordInput.jsx';
function flatten(nodes, acc = []) {
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
@@ -169,7 +170,7 @@ export default function UsersPanel() {
</Field>
{!selected && (
<Field label={t('user_password')}>
<input className="input" type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
<PasswordInput value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
</Field>
)}
</FieldRow>
+9
View File
@@ -124,6 +124,15 @@ button { font-family: inherit; }
.search-input input { padding-left: 32px; }
.search-input svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: rgb(var(--muted)); }
/* --- Password reveal toggle: input queda dentro para no romper el layout de .field --- */
.password-input { position: relative; width: 100%; }
.password-input input { padding-right: 34px; }
.password-input .password-toggle {
position: absolute; right: 2px; top: 50%; transform: translateY(-50%);
width: 28px; height: 28px;
}
.password-input .password-toggle svg { width: 16px; height: 16px; }
.list-card { background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); overflow: hidden; }
table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.list-table th {