feat: autorregistro con verificacion de correo + aprobacion, superadmin y SMTP
- POST /api/auth/register: primer usuario del sistema -> superadmin con sesion directa (bootstrap, sin correo ni aprobacion); los siguientes quedan pending_verification y reciben un correo con enlace de un solo uso (24h). - GET /api/auth/verify-email: pasa a pending_approval y notifica in-app a los admin/superadmin activos. - admin/superadmin aprueban (POST /api/users/:id/approve, eligen rol y departamento) o rechazan (POST /api/users/:id/reject, borra y audita el email) desde Organizacion -> Usuarios (nuevo badge de estado + acciones). - login() da mensaje especifico segun el status de la cuenta en vez del generico "credenciales invalidas". - Recuperacion de clave (forgot/reset-password): respuesta siempre generica, token de 1 hora de un solo uso. - Nuevo modulo settings: SMTP configurable SOLO por superadmin desde Configuracion -> Correo (SMTP); clave cifrada AES-256-GCM (ENCRYPTION_KEY) en la tabla system_settings, nunca expuesta por la API. sendMail() degrada a modo dev (loguea en vez de enviar) si no hay SMTP configurado, y nunca deja que un fallo de envio tumbe al que lo llama. - Migracion 002_registration_and_smtp.sql: rol superadmin, columnas de ciclo de vida en users (status, tokens), tabla system_settings, y promueve al usuario mas antiguo del sistema a superadmin. - Fix: React.StrictMode disparaba el efecto de VerifyEmailPage dos veces; la segunda llamada (token ya consumido) mostraba "enlace invalido" pese a que la verificacion ya habia funcionado. Se agrega una guarda por token. - Fix (deuda tecnica v1.0.0): se reordena el montaje de rutas en app.js — subtasksRoutes (mount generico en /api) va al final, para no interceptar con su requireAuth interno una request sin token dirigida a un router mas especifico registrado despues. - Documentacion actualizada: AGENTS.md, SPEC.md, API.md, BASE_DE_DATOS.md, CONFIGURACION.md, ESTADO.md, CHANGELOG.md. Version 1.1.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,10 @@ import { ToastHost } from '../components/ToastHost.jsx';
|
||||
import { ProtectedRoute } from './ProtectedRoute.jsx';
|
||||
import AppShell from './AppShell.jsx';
|
||||
import LoginPage from '../pages/Login/LoginPage.jsx';
|
||||
import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
||||
import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx';
|
||||
import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx';
|
||||
import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx';
|
||||
import DashboardPage from '../pages/Dashboard/DashboardPage.jsx';
|
||||
import OrganizacionPage from '../pages/Organizacion/OrganizacionPage.jsx';
|
||||
import ProyectosPage from '../pages/Proyectos/ProyectosPage.jsx';
|
||||
@@ -19,6 +23,10 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/registro" element={<RegisterPage />} />
|
||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
|
||||
@@ -32,7 +32,13 @@ export function AuthProvider({ children }) {
|
||||
return result.user;
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ user, ready, login, logout }), [user, ready, login, logout]);
|
||||
// Usado por RegisterPage cuando el registro devuelve sesion directa (primer usuario = superadmin).
|
||||
const setSession = useCallback((token, sessionUser) => {
|
||||
api.setToken(token);
|
||||
setUser(sessionUser);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ user, ready, login, logout, setSession }), [user, ready, login, logout, setSession]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,46 @@ export const messages = {
|
||||
login_password: 'Clave',
|
||||
login_submit: 'Entrar',
|
||||
login_error: 'Credenciales invalidas',
|
||||
login_forgot_password: '¿Olvidaste tu contraseña?',
|
||||
login_create_account: 'Crear cuenta',
|
||||
logout: 'Cerrar sesion',
|
||||
|
||||
register_title: 'Crear cuenta',
|
||||
register_name: 'Nombre completo',
|
||||
register_password_confirm: 'Confirmar clave',
|
||||
register_password_mismatch: 'Las claves no coinciden',
|
||||
register_password_short: 'La clave debe tener al menos 8 caracteres',
|
||||
register_submit: 'Registrarme',
|
||||
register_success: 'Cuenta creada. Revisa tu correo para confirmarla; luego un administrador debe aprobarla antes de que puedas entrar.',
|
||||
register_back_to_login: 'Volver a iniciar sesion',
|
||||
verify_email_title: 'Verificar correo',
|
||||
verify_email_success: 'Correo confirmado. Tu cuenta esta pendiente de aprobacion por un administrador.',
|
||||
verify_email_missing_token: 'Enlace invalido: falta el token de verificacion.',
|
||||
forgot_password_title: 'Recuperar clave',
|
||||
forgot_password_submit: 'Enviar instrucciones',
|
||||
forgot_password_sent: 'Si el correo existe, te enviamos instrucciones para recuperar tu clave.',
|
||||
reset_password_title: 'Elegir nueva clave',
|
||||
reset_password_new: 'Clave nueva',
|
||||
reset_password_submit: 'Guardar clave',
|
||||
reset_password_success: 'Clave actualizada. Redirigiendo a iniciar sesion...',
|
||||
|
||||
user_status_pending_verification: 'Pendiente de verificar correo',
|
||||
user_status_pending_approval: 'Pendiente de aprobacion',
|
||||
user_status_active: 'Activo',
|
||||
user_approve: 'Aprobar',
|
||||
user_reject: 'Rechazar',
|
||||
user_approve_title: 'Aprobar usuario',
|
||||
user_reject_confirm_msg: '¿Seguro que deseas rechazar esta solicitud de registro? Se eliminara permanentemente.',
|
||||
|
||||
config_smtp: 'Correo (SMTP)',
|
||||
config_smtp_superadmin_only: 'Solo el superadministrador puede configurar el correo del sistema.',
|
||||
smtp_host: 'Servidor (host)',
|
||||
smtp_port: 'Puerto',
|
||||
smtp_user: 'Usuario',
|
||||
smtp_from: 'Remitente',
|
||||
smtp_pass: 'Clave',
|
||||
smtp_pass_configured_hint: 'Configurada — dejar en blanco para no cambiarla',
|
||||
smtp_not_configured: 'Sin configurar: los correos se registran en el log del servidor (modo dev), no se envian de verdad.',
|
||||
common_new: 'Nuevo',
|
||||
common_edit: 'Editar',
|
||||
common_save: 'Guardar',
|
||||
@@ -145,7 +184,46 @@ export const messages = {
|
||||
login_password: 'Password',
|
||||
login_submit: 'Sign in',
|
||||
login_error: 'Invalid credentials',
|
||||
login_forgot_password: 'Forgot your password?',
|
||||
login_create_account: 'Create account',
|
||||
logout: 'Sign out',
|
||||
|
||||
register_title: 'Create account',
|
||||
register_name: 'Full name',
|
||||
register_password_confirm: 'Confirm password',
|
||||
register_password_mismatch: 'Passwords do not match',
|
||||
register_password_short: 'Password must be at least 8 characters',
|
||||
register_submit: 'Sign up',
|
||||
register_success: 'Account created. Check your email to confirm it; an administrator must approve it before you can sign in.',
|
||||
register_back_to_login: 'Back to sign in',
|
||||
verify_email_title: 'Verify email',
|
||||
verify_email_success: 'Email confirmed. Your account is pending administrator approval.',
|
||||
verify_email_missing_token: 'Invalid link: missing verification token.',
|
||||
forgot_password_title: 'Recover password',
|
||||
forgot_password_submit: 'Send instructions',
|
||||
forgot_password_sent: 'If the email exists, we sent instructions to recover your password.',
|
||||
reset_password_title: 'Choose a new password',
|
||||
reset_password_new: 'New password',
|
||||
reset_password_submit: 'Save password',
|
||||
reset_password_success: 'Password updated. Redirecting to sign in...',
|
||||
|
||||
user_status_pending_verification: 'Pending email verification',
|
||||
user_status_pending_approval: 'Pending approval',
|
||||
user_status_active: 'Active',
|
||||
user_approve: 'Approve',
|
||||
user_reject: 'Reject',
|
||||
user_approve_title: 'Approve user',
|
||||
user_reject_confirm_msg: 'Are you sure you want to reject this registration request? It will be permanently deleted.',
|
||||
|
||||
config_smtp: 'Email (SMTP)',
|
||||
config_smtp_superadmin_only: 'Only the superadmin can configure the system email.',
|
||||
smtp_host: 'Host',
|
||||
smtp_port: 'Port',
|
||||
smtp_user: 'User',
|
||||
smtp_from: 'From',
|
||||
smtp_pass: 'Password',
|
||||
smtp_pass_configured_hint: 'Configured — leave blank to keep it unchanged',
|
||||
smtp_not_configured: 'Not configured: emails are logged on the server (dev mode), not actually sent.',
|
||||
common_new: 'New',
|
||||
common_edit: 'Edit',
|
||||
common_save: 'Save',
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers } from 'lucide-react';
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { Expander } from '../../components/Expander.jsx';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { CatalogEditor } from './CatalogEditor.jsx';
|
||||
import SmtpSettingsPanel from './SmtpSettingsPanel.jsx';
|
||||
|
||||
export default function ConfiguracionPage() {
|
||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -84,6 +87,12 @@ export default function ConfiguracionPage() {
|
||||
fields={[{ key: 'name', label: t('common_name'), type: 'text' }]}
|
||||
/>
|
||||
</Expander>
|
||||
|
||||
{user?.roleCode === 'superadmin' && (
|
||||
<Expander icon={Mail} title={t('config_smtp')}>
|
||||
<SmtpSettingsPanel />
|
||||
</Expander>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { getSmtpConfig, updateSmtpConfig } from '../../services/settings.js';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
|
||||
export default function SmtpSettingsPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const runAction = useAction();
|
||||
const { data: config, reload } = useAsync(getSmtpConfig, []);
|
||||
const [form, setForm] = useState({ host: '', port: 587, user: '', from: '', pass: '' });
|
||||
|
||||
useEffect(() => {
|
||||
if (config) setForm({ host: config.host, port: config.port, user: config.user, from: config.from, pass: '' });
|
||||
}, [config]);
|
||||
|
||||
async function save() {
|
||||
await runAction(async () => {
|
||||
await updateSmtpConfig(form);
|
||||
await reload();
|
||||
setForm((f) => ({ ...f, pass: '' }));
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}
|
||||
|
||||
if (!config) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!config.configured && <InfoBar tone="warning" dismissible={false}>{t('smtp_not_configured')}</InfoBar>}
|
||||
<FieldRow>
|
||||
<Field label={t('smtp_host')}>
|
||||
<input className="input" value={form.host} onChange={(e) => setForm((f) => ({ ...f, host: e.target.value }))} placeholder="192.168.222.10" />
|
||||
</Field>
|
||||
<Field label={t('smtp_port')}>
|
||||
<input className="input" type="number" value={form.port} onChange={(e) => setForm((f) => ({ ...f, port: Number(e.target.value) }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('smtp_user')}>
|
||||
<input className="input" value={form.user} onChange={(e) => setForm((f) => ({ ...f, user: e.target.value }))} placeholder="project@linkedcorp.net" />
|
||||
</Field>
|
||||
<Field label={t('smtp_from')}>
|
||||
<input className="input" value={form.from} onChange={(e) => setForm((f) => ({ ...f, from: e.target.value }))} placeholder="project@linkedcorp.net" />
|
||||
</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 }))} />
|
||||
</Field>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { forgotPassword } from '../../services/auth.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 ForgotPasswordPage() {
|
||||
const { t, theme } = useUi();
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await forgotPassword(email);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSent(true); // siempre generico, exista o no el correo
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('forgot_password_title')}</div>
|
||||
{sent ? (
|
||||
<div className="login-info">{t('forgot_password_sent')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('login_email')}>
|
||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Button variant="accent" icon={Send} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{t('forgot_password_submit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="login-links">
|
||||
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
@@ -24,8 +24,8 @@ export default function LoginPage() {
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/', { replace: true });
|
||||
} catch {
|
||||
setError(t('login_error'));
|
||||
} catch (err) {
|
||||
setError(err.message || t('login_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -48,6 +48,10 @@ export default function LoginPage() {
|
||||
<Button variant="accent" icon={LogIn} type="submit" disabled={loading} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{t('login_submit')}
|
||||
</Button>
|
||||
<div className="login-links">
|
||||
<Link to="/olvide-clave">{t('login_forgot_password')}</Link>
|
||||
<Link to="/registro">{t('login_create_account')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
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 logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { setSession } = useAuth();
|
||||
const { t, theme } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [info, setInfo] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setInfo('');
|
||||
if (password !== confirm) { setError(t('register_password_mismatch')); return; }
|
||||
if (password.length < 8) { setError(t('register_password_short')); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await register({ name, email, password });
|
||||
if (result.bootstrap) {
|
||||
setSession(result.token, result.user);
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
setInfo(t('register_success'));
|
||||
} 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={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('register_title')}</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
{info && <div className="login-info">{info}</div>}
|
||||
{!info && (
|
||||
<>
|
||||
<Field label={t('register_name')}>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Field label={t('login_email')}>
|
||||
<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 />
|
||||
</Field>
|
||||
<Field label={t('register_password_confirm')}>
|
||||
<input className="input" type="password" 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')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="login-links">
|
||||
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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 { resetPassword } from '../../services/auth.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 ResetPasswordPage() {
|
||||
const { t, theme } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token') || '';
|
||||
const [password, setPassword] = 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 (password !== confirm) { setError(t('register_password_mismatch')); return; }
|
||||
if (password.length < 8) { setError(t('register_password_short')); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await resetPassword(token, password);
|
||||
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={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('reset_password_title')}</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
{success ? (
|
||||
<div className="login-info">{t('reset_password_success')}</div>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('reset_password_new')}>
|
||||
<input className="input" type="password" 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 />
|
||||
</Field>
|
||||
<Button variant="accent" icon={KeyRound} type="submit" disabled={loading || !token} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{t('reset_password_submit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="login-links">
|
||||
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { verifyEmail } from '../../services/auth.js';
|
||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const { t, theme } = useUi();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [state, setState] = useState('loading');
|
||||
const [message, setMessage] = useState('');
|
||||
// El token de verificacion se consume en el servidor (un solo uso): con React.StrictMode el
|
||||
// efecto se dispara dos veces en dev, y la segunda llamada fallaria con "token invalido" aunque
|
||||
// la primera ya haya verificado correctamente. Esta ref evita el segundo disparo.
|
||||
const requestedTokenRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
if (!token) { setState('error'); setMessage(t('verify_email_missing_token')); return; }
|
||||
if (requestedTokenRef.current === token) return;
|
||||
requestedTokenRef.current = token;
|
||||
verifyEmail(token)
|
||||
.then(() => { setState('success'); setMessage(t('verify_email_success')); })
|
||||
.catch((err) => { setState('error'); setMessage(err.message || t('common_error')); });
|
||||
}, [searchParams, t]);
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-card">
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('verify_email_title')}</div>
|
||||
{state === 'loading' && <div className="text-muted">{t('common_loading')}</div>}
|
||||
{state === 'success' && <div className="login-info">{message}</div>}
|
||||
{state === 'error' && <div className="login-error">{message}</div>}
|
||||
<div className="login-links">
|
||||
<Link to="/login">{t('register_back_to_login')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Save, UserX, UserCheck } from 'lucide-react';
|
||||
import { Plus, Save, UserX, UserCheck, Check, X as XIcon } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { listUsers, listRoles, createUser, updateUser, setUserActive } from '../../services/users.js';
|
||||
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser } from '../../services/users.js';
|
||||
import { listDepartments } from '../../services/departments.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||
@@ -17,19 +18,25 @@ function flatten(nodes, acc = []) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success' };
|
||||
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
|
||||
|
||||
export default function UsersPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const { t, pushToast, openDialog } = useUi();
|
||||
const { user: currentUser } = useAuth();
|
||||
const runAction = useAction();
|
||||
const { data: users, reload } = useAsync(listUsers, []);
|
||||
const { data: roles } = useAsync(listRoles, []);
|
||||
const { data: deptTree } = useAsync(listDepartments, []);
|
||||
|
||||
const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [approving, setApproving] = useState(null);
|
||||
const [approveForm, setApproveForm] = useState({ roleId: '', departmentId: '' });
|
||||
|
||||
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||
const roleOptions = (roles || []).map((r) => ({ value: r.id, label: r.name }));
|
||||
@@ -41,6 +48,7 @@ export default function UsersPanel() {
|
||||
}
|
||||
|
||||
function startEdit(user) {
|
||||
if (!canManage) return;
|
||||
setSelected(user);
|
||||
setForm({ name: user.name, email: user.email, roleId: user.role_id, departmentId: user.department_id || '', position: user.position || '', password: '' });
|
||||
setEditing(true);
|
||||
@@ -68,13 +76,40 @@ export default function UsersPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
function startApprove(user) {
|
||||
const collaboratorRole = (roles || []).find((r) => r.code === 'collaborator');
|
||||
setApproving(user);
|
||||
setApproveForm({ roleId: user.role_id || collaboratorRole?.id || '', departmentId: '' });
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
await runAction(async () => {
|
||||
await approveUser(approving.id, { roleId: approveForm.roleId, departmentId: approveForm.departmentId || null });
|
||||
setApproving(null);
|
||||
await reload();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReject(user) {
|
||||
openDialog({
|
||||
tone: 'warning',
|
||||
title: t('user_reject'),
|
||||
message: t('user_reject_confirm_msg'),
|
||||
actions: [
|
||||
{ label: t('common_cancel'), variant: 'standard' },
|
||||
{ label: t('user_reject'), variant: 'danger', onClick: () => runAction(async () => { await rejectUser(user.id); await reload(); }) },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: t('common_name') },
|
||||
{ key: 'email', label: t('user_email') },
|
||||
{ key: 'role_name', label: t('user_role') },
|
||||
{ key: 'department_name', label: t('common_department'), render: (r) => r.department_name || '—' },
|
||||
{ key: 'is_active', label: t('common_status'), sortable: false, render: (r) => (
|
||||
<Badge color={r.is_active ? 'success' : 'muted'}>{r.is_active ? t('common_active') : t('common_inactive')}</Badge>
|
||||
{ key: 'status', label: t('common_status'), sortable: false, render: (r) => (
|
||||
<Badge color={STATUS_COLOR[r.status] || 'muted'}>{t(`user_status_${r.status}`)}</Badge>
|
||||
) },
|
||||
];
|
||||
|
||||
@@ -86,7 +121,7 @@ export default function UsersPanel() {
|
||||
<div className="toolbar-left">
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
</div>
|
||||
<Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>
|
||||
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}</Button>}
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
@@ -95,13 +130,20 @@ export default function UsersPanel() {
|
||||
searchValue={search}
|
||||
getSearchHaystack={(u) => `${u.name} ${u.email} ${u.role_name} ${u.department_name || ''}`}
|
||||
getRowKey={(u) => u.id}
|
||||
onRowClick={startEdit}
|
||||
rowActions={(u) => [
|
||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||
u.is_active
|
||||
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
||||
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
|
||||
]}
|
||||
onRowClick={canManage ? startEdit : undefined}
|
||||
rowActions={canManage ? (u) => (
|
||||
u.status === 'pending_approval'
|
||||
? [
|
||||
{ label: t('user_approve'), icon: Check, onClick: () => startApprove(u) },
|
||||
{ label: t('user_reject'), icon: XIcon, danger: true, onClick: () => confirmReject(u) },
|
||||
]
|
||||
: [
|
||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||
u.is_active
|
||||
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
||||
: { label: t('common_active'), icon: UserCheck, onClick: () => toggleActive(u) },
|
||||
]
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => setEditing(false)} width={560} height={440}>
|
||||
@@ -136,6 +178,21 @@ export default function UsersPanel() {
|
||||
<Button variant="standard" onClick={() => setEditing(false)}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={!!approving} title={t('user_approve_title')} onClose={() => setApproving(null)} width={420} height={300}>
|
||||
<FieldRow>
|
||||
<Field label={t('user_role')}>
|
||||
<ComboBox options={roleOptions} value={approveForm.roleId} onChange={(v) => setApproveForm((f) => ({ ...f, roleId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('common_department')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={approveForm.departmentId} onChange={(v) => setApproveForm((f) => ({ ...f, departmentId: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Check} onClick={confirmApprove}>{t('user_approve')}</Button>
|
||||
<Button variant="standard" onClick={() => setApproving(null)}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
export const register = (data) => api.post('/auth/register', data);
|
||||
export const verifyEmail = (token) => api.get(`/auth/verify-email?token=${encodeURIComponent(token)}`);
|
||||
export const forgotPassword = (email) => api.post('/auth/forgot-password', { email });
|
||||
export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password });
|
||||
@@ -0,0 +1,4 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
export const getSmtpConfig = () => api.get('/settings/smtp');
|
||||
export const updateSmtpConfig = (data) => api.put('/settings/smtp', data);
|
||||
@@ -5,3 +5,5 @@ export const listRoles = () => api.get('/users/roles');
|
||||
export const createUser = (data) => api.post('/users', data);
|
||||
export const updateUser = (id, data) => api.put(`/users/${id}`, data);
|
||||
export const setUserActive = (id, isActive) => api.patch(`/users/${id}/active`, { isActive });
|
||||
export const approveUser = (id, data) => api.post(`/users/${id}/approve`, data);
|
||||
export const rejectUser = (id) => api.post(`/users/${id}/reject`);
|
||||
|
||||
@@ -257,6 +257,10 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
||||
.login-logo-img { height: 80px; width: auto; }
|
||||
.login-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 16px; }
|
||||
.login-error { color: rgb(var(--danger)); font-size: .78rem; margin-bottom: 10px; }
|
||||
.login-info { color: rgb(var(--success)); font-size: .82rem; margin-bottom: 10px; line-height: 1.5; }
|
||||
.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; }
|
||||
|
||||
/* --- DetailPopup (ver DetailPopup.jsx) ---
|
||||
Al elegir un item de un listado, la lista queda inactiva detras del overlay y el popup
|
||||
|
||||
Reference in New Issue
Block a user