feat: version 4.3.0 -- Sesiones activas y pagina Mi cuenta
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.2.2",
|
||||
"version": "4.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -23,6 +23,7 @@ import NotasPage from '../pages/Notas/NotasPage.jsx';
|
||||
import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx';
|
||||
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
|
||||
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
|
||||
import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx';
|
||||
|
||||
// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una
|
||||
// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes.
|
||||
@@ -37,6 +38,7 @@ const router = createBrowserRouter(createRoutesFromElements(
|
||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="mi-cuenta" element={<MiCuentaPage />} />
|
||||
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
|
||||
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
|
||||
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
|
||||
|
||||
@@ -1,37 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Menu, Sun, Moon, Bell, LogOut, User, Globe, KeyRound, Save } from 'lucide-react';
|
||||
import { Menu, Sun, Moon, Bell, LogOut, User, Globe, UserCog } from 'lucide-react';
|
||||
import { NavigationView } from '../components/NavigationView.jsx';
|
||||
import { NavigationGuard } from '../components/NavigationGuard.jsx';
|
||||
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';
|
||||
import { listNotifications, markAllNotificationsRead, markNotificationRead } from '../services/notifications.js';
|
||||
import { changePassword } from '../services/auth.js';
|
||||
import { getUpdateStatus } from '../services/updater.js';
|
||||
|
||||
const EMPTY_PASSWORD_FORM = { currentPassword: '', newPassword: '', confirmPassword: '' };
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export default function AppShell() {
|
||||
const [navMode, setNavMode] = useState('expanded');
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
const { t, theme, setTheme, lang, setLang, pushToast, openDialog } = useUi();
|
||||
const { t, theme, setTheme, lang, setLang, openDialog } = useUi();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: notifData, reload: reloadNotifications } = useAsync(listNotifications, []);
|
||||
const unread = notifData?.unread || 0;
|
||||
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [passwordForm, setPasswordForm] = useState(EMPTY_PASSWORD_FORM);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
// Al entrar (una vez por sesion, no en cada navegacion): si es superadmin y hay una
|
||||
// actualizacion pendiente, avisar con un popup y un acceso directo al panel. Silencioso si
|
||||
@@ -80,31 +70,6 @@ export default function AppShell() {
|
||||
else if (notification.entity_type === 'activity') navigate(`/proyectos`);
|
||||
}
|
||||
|
||||
function openChangePassword() {
|
||||
setPasswordForm(EMPTY_PASSWORD_FORM);
|
||||
setPasswordError('');
|
||||
setChangingPassword(true);
|
||||
}
|
||||
|
||||
async function submitChangePassword() {
|
||||
setPasswordError('');
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
setPasswordError(t('register_password_mismatch'));
|
||||
return;
|
||||
}
|
||||
if (passwordForm.newPassword.length < 8) {
|
||||
setPasswordError(t('register_password_short'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await changePassword(passwordForm.currentPassword, passwordForm.newPassword);
|
||||
setChangingPassword(false);
|
||||
pushToast({ tone: 'success', message: t('change_password_success') });
|
||||
} catch (err) {
|
||||
setPasswordError(err.message || t('common_error'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<NavigationView mode={navMode} mobileOpen={mobileNavOpen} onNavigate={closeMobileNav} />
|
||||
@@ -157,10 +122,10 @@ export default function AppShell() {
|
||||
<div style={{ fontSize: '.7rem', color: 'rgb(var(--muted))' }}>{user?.roleName || user?.roleCode}</div>
|
||||
</div>
|
||||
<FlyoutSeparator />
|
||||
<FlyoutItem icon={KeyRound} onClick={openChangePassword}>
|
||||
{t('change_password_title')}
|
||||
<FlyoutItem icon={UserCog} onClick={() => navigate('/mi-cuenta')}>
|
||||
{t('account_title')}
|
||||
</FlyoutItem>
|
||||
<FlyoutItem icon={LogOut} danger onClick={() => { logout(); navigate('/login'); }}>
|
||||
<FlyoutItem icon={LogOut} danger onClick={() => { logout({ remote: true }); navigate('/login'); }}>
|
||||
{t('logout')}
|
||||
</FlyoutItem>
|
||||
</Flyout>
|
||||
@@ -177,22 +142,6 @@ export default function AppShell() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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')}>
|
||||
<PasswordInput value={passwordForm.currentPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, currentPassword: e.target.value }))} autoFocus />
|
||||
</Field>
|
||||
<Field label={t('reset_password_new')}>
|
||||
<PasswordInput value={passwordForm.newPassword} onChange={(e) => setPasswordForm((f) => ({ ...f, newPassword: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('register_password_confirm')}>
|
||||
<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>
|
||||
<Button variant="standard" onClick={() => setChangingPassword(false)}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</DetailPopup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
||||
<tr
|
||||
key={getRowKey(row)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
onContextMenu={rowActions ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined}
|
||||
tabIndex={onRowClick ? 0 : undefined}
|
||||
onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }}
|
||||
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { LogOut, RefreshCw } from 'lucide-react';
|
||||
import { useUi } from '../context/ui-context.jsx';
|
||||
import { ListView } from './ListView.jsx';
|
||||
import { Button } from './Button.jsx';
|
||||
import { Badge } from './Badge.jsx';
|
||||
|
||||
export function SessionsList({ load, revoke, revokeAll, onCurrentRevoked, allLabel }) {
|
||||
const { t, lang, openDialog, pushToast } = useUi();
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setSessions(await load()); }
|
||||
catch (error) { pushToast({ tone: 'error', message: error.message }); }
|
||||
finally { setLoading(false); }
|
||||
}, [load, pushToast]);
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
function confirm(action, message, current = false) {
|
||||
openDialog({ tone: 'warning', title: t('sessions_close'), message,
|
||||
actions: [
|
||||
{ label: t('common_cancel'), variant: 'standard' },
|
||||
{ label: t('sessions_close'), variant: 'danger', onClick: async () => {
|
||||
try {
|
||||
const result = await action();
|
||||
if (current || result?.current) onCurrentRevoked?.();
|
||||
else { await reload(); pushToast({ tone: 'success', message: t('sessions_closed') }); }
|
||||
} catch (error) { pushToast({ tone: 'error', message: error.message }); }
|
||||
} },
|
||||
] });
|
||||
}
|
||||
const date = (value) => value ? new Date(value.replace(' ', 'T')).toLocaleString(lang) : '—';
|
||||
const columns = [
|
||||
{ key: 'browser', label: t('sessions_device'), render: (row) => <>{row.browser === 'unknown' ? t('sessions_unknown') : row.browser} · {row.os === 'unknown' ? t('sessions_unknown') : row.os} {row.current && <Badge color="accent">{t('sessions_current')}</Badge>}</> },
|
||||
{ key: 'ip', label: t('sessions_ip'), render: (row) => row.ip || '—' },
|
||||
{ key: 'created_at', label: t('sessions_started'), render: (row) => date(row.created_at) },
|
||||
{ key: 'last_seen_at', label: t('sessions_seen'), render: (row) => date(row.last_seen_at) },
|
||||
{ key: 'close', label: t('sessions_close'), sortable: false, render: (row) => <Button size="sm" variant="danger" icon={LogOut} onClick={(event) => { event.stopPropagation(); confirm(() => revoke(row.id), t('sessions_confirm'), row.current); }}>{t('sessions_close')}</Button> },
|
||||
];
|
||||
return <div>
|
||||
<div className="toolbar"><div className="toolbar-left">
|
||||
<Button icon={RefreshCw} onClick={reload}>{t('sessions_refresh')}</Button>
|
||||
{sessions.some((session) => allLabel || !session.current) && <Button variant="danger" icon={LogOut} onClick={() => confirm(revokeAll, t('sessions_confirm_all'))}>{allLabel || t('sessions_close_others')}</Button>}
|
||||
</div></div>
|
||||
{loading ? <div className="text-muted">{t('common_loading')}</div> : <ListView columns={columns} rows={sessions} getRowKey={(row) => row.id}
|
||||
rowActions={(row) => [{ label: t('sessions_close'), icon: LogOut, danger: true, onClick: () => confirm(() => revoke(row.id), t('sessions_confirm'), row.current) }]} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const userRef = useRef(null);
|
||||
const lastPingRef = useRef(0);
|
||||
userRef.current = user;
|
||||
|
||||
const logout = useCallback((options = {}) => {
|
||||
@@ -19,21 +20,22 @@ export function AuthProvider({ children }) {
|
||||
// (los borradores ya estan guardados en el navegador).
|
||||
setGuardBypass(true);
|
||||
if (!options.preserveDrafts) clearUserDrafts(userRef.current?.id);
|
||||
if (options.remote) api.post('/auth/logout').catch(() => {});
|
||||
api.setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
// Vencimiento: recuerda donde estaba el usuario (para volver ahi tras el login) y marca el
|
||||
// aviso que muestra la pantalla de login. Idempotente.
|
||||
const expireSession = useCallback(() => {
|
||||
const expireSession = useCallback((info = {}) => {
|
||||
if (!localStorage.getItem('plc_token')) return;
|
||||
if (userRef.current) saveReturnContext(userRef.current.id);
|
||||
markSessionExpired();
|
||||
saveReturnContext(userRef.current?.id || tokenSubject(localStorage.getItem('plc_token')));
|
||||
markSessionExpired(info.code === 'SESSION_REVOKED');
|
||||
logout({ preserveDrafts: true });
|
||||
}, [logout]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => expireSession());
|
||||
setUnauthorizedHandler((info) => expireSession(info));
|
||||
}, [expireSession]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,9 +49,10 @@ export function AuthProvider({ children }) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
lastPingRef.current = Date.now();
|
||||
api.get('/auth/me')
|
||||
.then((me) => setUser(me))
|
||||
.catch(() => logout())
|
||||
.catch((error) => { if (error.status !== 401) logout(); })
|
||||
.finally(() => setReady(true));
|
||||
}, [logout]);
|
||||
|
||||
@@ -59,7 +62,11 @@ export function AuthProvider({ children }) {
|
||||
useEffect(() => {
|
||||
if (!user) return undefined;
|
||||
const check = () => {
|
||||
if (isTokenExpired(localStorage.getItem('plc_token'), 0)) expireSession();
|
||||
if (isTokenExpired(localStorage.getItem('plc_token'), 0)) { expireSession(); return; }
|
||||
if (document.visibilityState === 'visible' && Date.now() - lastPingRef.current >= 60000) {
|
||||
lastPingRef.current = Date.now();
|
||||
api.get('/auth/me').catch(() => {});
|
||||
}
|
||||
};
|
||||
const exp = tokenExpiresAt(localStorage.getItem('plc_token'));
|
||||
const timer = exp ? setTimeout(check, Math.min(Math.max(exp - Date.now(), 0) + 500, MAX_TIMEOUT_MS)) : null;
|
||||
@@ -81,6 +88,7 @@ export function AuthProvider({ children }) {
|
||||
// Devuelve la ruta a la que volver tras iniciar sesion (solo si la dejo este mismo usuario).
|
||||
const startSession = useCallback((token, sessionUser) => {
|
||||
api.setToken(token);
|
||||
lastPingRef.current = Date.now();
|
||||
clearSessionExpiredFlag();
|
||||
setGuardBypass(false);
|
||||
setUser(sessionUser);
|
||||
|
||||
@@ -267,6 +267,16 @@ export const messages = {
|
||||
draft_keep_mine: 'Mantener mis cambios',
|
||||
draft_use_server: 'Descartar mis cambios',
|
||||
session_expired_login_msg: 'Tu sesión venció. Inicia sesión otra vez para volver a donde estabas; los cambios sin guardar se conservaron.',
|
||||
session_revoked_login_msg: 'Tu sesión fue cerrada desde otro dispositivo o por un administrador. Inicia sesión otra vez para volver a donde estabas.',
|
||||
account_title: 'Mi cuenta',
|
||||
account_tab_profile: 'Perfil', account_tab_preferences: 'Preferencias', account_tab_security: 'Seguridad', account_tab_sessions: 'Sesiones activas',
|
||||
account_language: 'Idioma', account_theme: 'Tema', account_buttons: 'Apariencia de botones',
|
||||
change_password_sessions_closed: 'Clave actualizada. Se cerraron las demás sesiones.',
|
||||
sessions_title: 'Sesiones', sessions_device: 'Navegador y sistema', sessions_ip: 'IP', sessions_started: 'Inicio', sessions_seen: 'Última actividad',
|
||||
sessions_current: 'Esta sesión', sessions_close: 'Cerrar sesión', sessions_close_others: 'Cerrar todas las demás', sessions_close_all: 'Cerrar todas',
|
||||
sessions_confirm: '¿Quieres cerrar esta sesión?', sessions_confirm_all: '¿Quieres cerrar estas sesiones?', sessions_closed: 'Sesiones cerradas.',
|
||||
sessions_refresh: 'Actualizar',
|
||||
sessions_unknown: 'Desconocido',
|
||||
dashboard_cards: 'Tarjetas',
|
||||
dashboard_no_cards: 'No hay tarjetas seleccionadas. Elige cuáles mostrar en "Tarjetas".',
|
||||
dashboard_projects_by_status: 'Proyectos por estado',
|
||||
@@ -868,6 +878,16 @@ export const messages = {
|
||||
draft_keep_mine: 'Keep my changes',
|
||||
draft_use_server: 'Discard my changes',
|
||||
session_expired_login_msg: 'Your session expired. Sign in again to return where you were; unsaved changes were kept.',
|
||||
session_revoked_login_msg: 'Your session was closed from another device or by an administrator. Sign in again to return where you were.',
|
||||
account_title: 'My account',
|
||||
account_tab_profile: 'Profile', account_tab_preferences: 'Preferences', account_tab_security: 'Security', account_tab_sessions: 'Active sessions',
|
||||
account_language: 'Language', account_theme: 'Theme', account_buttons: 'Button appearance',
|
||||
change_password_sessions_closed: 'Password updated. Your other sessions were closed.',
|
||||
sessions_title: 'Sessions', sessions_device: 'Browser and system', sessions_ip: 'IP', sessions_started: 'Started', sessions_seen: 'Last activity',
|
||||
sessions_current: 'This session', sessions_close: 'Close session', sessions_close_others: 'Close all others', sessions_close_all: 'Close all',
|
||||
sessions_confirm: 'Do you want to close this session?', sessions_confirm_all: 'Do you want to close these sessions?', sessions_closed: 'Sessions closed.',
|
||||
sessions_refresh: 'Refresh',
|
||||
sessions_unknown: 'Unknown',
|
||||
dashboard_cards: 'Cards',
|
||||
dashboard_no_cards: 'No cards selected. Choose which ones to show in "Cards".',
|
||||
dashboard_projects_by_status: 'Projects by status',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||
import { readSessionExpiredFlag } from '../../utils/session.js';
|
||||
import { readSessionExpiredFlag, readSessionRevokedFlag } from '../../utils/session.js';
|
||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
import logoLinkedCorpIcon from '../../assets/logoLinkedTrackIcon.png';
|
||||
@@ -21,6 +21,7 @@ export default function LoginPage() {
|
||||
const location = useLocation();
|
||||
// Aviso de sesion vencida (se lee una sola vez al montar el login).
|
||||
const [sessionExpired] = useState(() => readSessionExpiredFlag());
|
||||
const [sessionRevoked] = useState(() => readSessionRevokedFlag());
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -36,7 +37,7 @@ export default function LoginPage() {
|
||||
const { returnTo } = await login(email, password);
|
||||
// Tras un vencimiento solo vale returnTo (verificado contra el usuario que entra): la ruta
|
||||
// "from" de la redireccion podria ser la de OTRO usuario que uso este navegador.
|
||||
const from = sessionExpired ? null : location.state?.from;
|
||||
const from = sessionExpired || sessionRevoked ? null : location.state?.from;
|
||||
navigate(returnTo || (typeof from === 'string' && from.startsWith('/') ? from : '/'), { replace: true });
|
||||
} catch (err) {
|
||||
setError(err.message || t('login_error'));
|
||||
@@ -55,6 +56,9 @@ export default function LoginPage() {
|
||||
{sessionExpired && !error && (
|
||||
<InfoBar tone="warning" dismissible={false}>{t('session_expired_login_msg')}</InfoBar>
|
||||
)}
|
||||
{sessionRevoked && !error && (
|
||||
<InfoBar tone="warning" dismissible={false}>{t('session_revoked_login_msg')}</InfoBar>
|
||||
)}
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<Field label={t('login_email')}>
|
||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { Tabs } from '../../components/Tabs.jsx';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
import { SessionsList } from '../../components/SessionsList.jsx';
|
||||
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||
import { changePassword, listSessions, revokeSession, revokeOtherSessions } from '../../services/auth.js';
|
||||
|
||||
const EMPTY = { currentPassword: '', newPassword: '', confirmPassword: '' };
|
||||
const TABS = ['profile', 'preferences', 'security', 'sessions'];
|
||||
|
||||
export default function MiCuentaPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const { t, lang, setLang, theme, setTheme, buttonMode, setButtonMode, pushToast } = useUi();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState('');
|
||||
const tab = TABS.includes(params.get('tab')) ? params.get('tab') : 'profile';
|
||||
const draft = useDraftForm({ formId: 'account-password', initial: EMPTY, label: t('change_password_title'), enabled: tab === 'security' });
|
||||
const form = draft.values;
|
||||
const setForm = draft.setValues;
|
||||
const load = useCallback(() => listSessions(), []);
|
||||
const revoke = useCallback((id) => revokeSession(id), []);
|
||||
const revokeOthers = useCallback(() => revokeOtherSessions(), []);
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
if (form.newPassword !== form.confirmPassword) { setError(t('register_password_mismatch')); return; }
|
||||
if (form.newPassword.length < 8) { setError(t('register_password_short')); return; }
|
||||
try {
|
||||
await changePassword(form.currentPassword, form.newPassword);
|
||||
draft.clear();
|
||||
pushToast({ tone: 'success', message: t('change_password_sessions_closed') });
|
||||
} catch (err) { setError(err.message || t('common_error')); }
|
||||
}
|
||||
|
||||
return <div>
|
||||
<h2 className="page-title mt-0">{t('account_title')}</h2>
|
||||
<Tabs tabs={TABS.map((key) => ({ key, label: t(`account_tab_${key}`) }))} active={tab} onChange={(key) => setParams({ tab: key })} />
|
||||
<div style={{ paddingTop: 20 }}>
|
||||
{tab === 'profile' && <div className="card" style={{ padding: 20 }}><FieldRow>
|
||||
<Field label={t('common_name')}><div>{user?.name || '—'}</div></Field>
|
||||
<Field label={t('user_email')}><div>{user?.email || '—'}</div></Field>
|
||||
<Field label={t('user_role')}><div>{user?.roleName || user?.roleCode || '—'}</div></Field>
|
||||
<Field label={t('common_department')}><div>{user?.departmentName || '—'}</div></Field>
|
||||
</FieldRow></div>}
|
||||
{tab === 'preferences' && <div className="card" style={{ padding: 20 }}><FieldRow>
|
||||
<Field label={t('account_language')}><ComboBox value={lang} onChange={setLang} options={[{ value: 'es-EC', label: t('lang_es') }, { value: 'en-US', label: t('lang_en') }]} /></Field>
|
||||
<Field label={t('account_theme')}><ComboBox value={theme} onChange={setTheme} options={[{ value: 'dark', label: t('theme_dark') }, { value: 'light', label: t('theme_light') }]} /></Field>
|
||||
<Field label={t('account_buttons')}><ComboBox value={buttonMode} onChange={setButtonMode} options={[{ value: 'icon-text', label: t('button_mode_icon_text') }, { value: 'icon', label: t('button_mode_icon') }, { value: 'text', label: t('button_mode_text') }]} /></Field>
|
||||
</FieldRow></div>}
|
||||
{tab === 'security' && <form className="card" style={{ padding: 20, maxWidth: 480 }} onSubmit={submit}>
|
||||
<h3>{t('change_password_title')}</h3>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<Field label={t('change_password_current')}><PasswordInput value={form.currentPassword} onChange={(e) => setForm((f) => ({ ...f, currentPassword: e.target.value }))} required /></Field>
|
||||
<Field label={t('reset_password_new')}><PasswordInput value={form.newPassword} onChange={(e) => setForm((f) => ({ ...f, newPassword: e.target.value }))} required /></Field>
|
||||
<Field label={t('register_password_confirm')}><PasswordInput value={form.confirmPassword} onChange={(e) => setForm((f) => ({ ...f, confirmPassword: e.target.value }))} required /></Field>
|
||||
<Button type="submit" variant="accent">{t('common_save')}</Button>
|
||||
</form>}
|
||||
{tab === 'sessions' && <SessionsList load={load} revoke={revoke} revokeAll={revokeOthers} onCurrentRevoked={() => { logout(); navigate('/login'); }} />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail, LayoutGrid } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Save, UserX, UserCheck, Check, X as XIcon, Mail, LayoutGrid, Monitor } 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, approveUser, rejectUser, resendUserEmail } from '../../services/users.js';
|
||||
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js';
|
||||
import { listDepartments } from '../../services/departments.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||
@@ -17,6 +18,7 @@ import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||
import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
import { SessionsList } from '../../components/SessionsList.jsx';
|
||||
|
||||
function flatten(nodes, acc = []) {
|
||||
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
||||
@@ -28,7 +30,8 @@ const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position
|
||||
|
||||
export default function UsersPanel() {
|
||||
const { t, pushToast, openDialog } = useUi();
|
||||
const { user: currentUser } = useAuth();
|
||||
const { user: currentUser, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const runAction = useAction();
|
||||
const { data: users, reload } = useAsync(listUsers, []);
|
||||
const { data: roles } = useAsync(listRoles, []);
|
||||
@@ -37,6 +40,10 @@ export default function UsersPanel() {
|
||||
const canManage = ['admin', 'superadmin'].includes(currentUser?.roleCode);
|
||||
const isSuperadmin = currentUser?.roleCode === 'superadmin';
|
||||
const [modulesUser, setModulesUser] = useState(null);
|
||||
const [sessionsUser, setSessionsUser] = useState(null);
|
||||
const loadSessions = useCallback(() => listUserSessions(sessionsUser.id), [sessionsUser]);
|
||||
const revokeSession = useCallback((id) => revokeUserSession(sessionsUser.id, id), [sessionsUser]);
|
||||
const revokeAllSessions = useCallback(() => revokeAllUserSessions(sessionsUser.id), [sessionsUser]);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -182,6 +189,7 @@ export default function UsersPanel() {
|
||||
}
|
||||
return [
|
||||
{ label: t('common_edit'), onClick: () => startEdit(u) },
|
||||
...(u.role_code !== 'superadmin' || isSuperadmin ? [{ label: t('sessions_title'), icon: Monitor, onClick: () => setSessionsUser(u) }] : []),
|
||||
...(isSuperadmin && u.role_code !== 'superadmin' ? [{ label: t('user_modules'), icon: LayoutGrid, onClick: () => setModulesUser(u) }] : []),
|
||||
u.is_active
|
||||
? { label: t('common_inactive'), icon: UserX, danger: true, onClick: () => toggleActive(u) }
|
||||
@@ -191,6 +199,10 @@ export default function UsersPanel() {
|
||||
/>
|
||||
|
||||
{modulesUser && <UserModulesDialog user={modulesUser} onClose={() => setModulesUser(null)} />}
|
||||
<DetailPopup open={!!sessionsUser} title={`${t('sessions_title')}: ${sessionsUser?.name || ''}`} onClose={() => setSessionsUser(null)} width={850} height={540}>
|
||||
{sessionsUser && <SessionsList load={loadSessions} revoke={revokeSession} revokeAll={revokeAllSessions}
|
||||
allLabel={t('sessions_close_all')} onCurrentRevoked={() => { logout(); navigate('/login'); }} />}
|
||||
</DetailPopup>
|
||||
|
||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
||||
<DraftNotice draft={draft} />
|
||||
|
||||
@@ -33,19 +33,18 @@ async function request(method, path, { body, isForm, extraHeaders, signal } = {}
|
||||
const refreshedVaultToken = res.headers.get('x-vault-token');
|
||||
if (refreshedVaultToken) sessionStorage.setItem('plc_vault_token', refreshedVaultToken);
|
||||
|
||||
if (res.status === 401 && token) {
|
||||
onUnauthorized({ expired: true });
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const isJson = (res.headers.get('content-type') || '').includes('application/json');
|
||||
const payload = isJson ? await res.json() : null;
|
||||
|
||||
if (res.status === 401 && token) onUnauthorized({ code: payload?.code });
|
||||
|
||||
if (!res.ok) {
|
||||
const message = payload?.error || `Error ${res.status}`;
|
||||
const error = new Error(message);
|
||||
error.status = res.status;
|
||||
error.code = payload?.code;
|
||||
throw error;
|
||||
}
|
||||
return payload?.data;
|
||||
|
||||
@@ -5,5 +5,8 @@ export const verifyEmail = (token) => api.get(`/auth/verify-email?token=${encode
|
||||
export const forgotPassword = (email) => api.post('/auth/forgot-password', { email });
|
||||
export const resetPassword = (token, password) => api.post('/auth/reset-password', { token, password });
|
||||
export const changePassword = (currentPassword, newPassword) => api.put('/auth/change-password', { currentPassword, newPassword });
|
||||
export const listSessions = () => api.get('/auth/sessions');
|
||||
export const revokeSession = (id) => api.delete(`/auth/sessions/${id}`);
|
||||
export const revokeOtherSessions = () => api.post('/auth/sessions/revoke-others');
|
||||
export const getInviteInfo = (token) => api.get(`/auth/invite?token=${encodeURIComponent(token)}`);
|
||||
export const acceptInvite = (token, name, password) => api.post('/auth/accept-invite', { token, name, password });
|
||||
|
||||
@@ -10,3 +10,6 @@ export const rejectUser = (id) => api.post(`/users/${id}/reject`);
|
||||
export const resendUserEmail = (id) => api.post(`/users/${id}/resend`);
|
||||
export const getUserModules = (id) => api.get(`/users/${id}/modules`);
|
||||
export const setUserModules = (id, modules) => api.put(`/users/${id}/modules`, { modules });
|
||||
export const listUserSessions = (id) => api.get(`/users/${id}/sessions`);
|
||||
export const revokeUserSession = (id, sessionId) => api.delete(`/users/${id}/sessions/${sessionId}`);
|
||||
export const revokeAllUserSessions = (id) => api.post(`/users/${id}/sessions/revoke-all`);
|
||||
|
||||
@@ -22,6 +22,7 @@ const DRAFT_FIELDS = {
|
||||
'user-new': ['roleId', 'departmentId'],
|
||||
'user-edit': ['editVersion', 'roleId', 'departmentId'],
|
||||
'user-approve': ['editVersion', 'roleId', 'departmentId'],
|
||||
'account-password': [],
|
||||
'recurring-new': ['projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
|
||||
'recurring-edit': ['editVersion', 'projectId', 'responsibleUserId', 'intervalUnit', 'intervalValue', 'nextDueDate', 'endDate', 'reminderDaysBefore', 'reminderHour'],
|
||||
'device-edit': ['editVersion', 'status', 'assignedUserId', 'departmentId'],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// limitado; otro usuario que inicie sesion en el mismo navegador la descarta sin verla.
|
||||
const RETURN_KEY = 'plc_return_ctx';
|
||||
const EXPIRED_FLAG = 'plc_session_expired';
|
||||
const REVOKED_FLAG = 'plc_session_revoked';
|
||||
const RETURN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function saveReturnContext(userId) {
|
||||
@@ -30,8 +31,15 @@ export function consumeReturnContext(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
export function markSessionExpired() {
|
||||
try { sessionStorage.setItem(EXPIRED_FLAG, '1'); } catch { /* aviso opcional */ }
|
||||
export function markSessionExpired(revoked = false) {
|
||||
try {
|
||||
sessionStorage.setItem(revoked ? REVOKED_FLAG : EXPIRED_FLAG, '1');
|
||||
sessionStorage.removeItem(revoked ? EXPIRED_FLAG : REVOKED_FLAG);
|
||||
} catch { /* aviso opcional */ }
|
||||
}
|
||||
|
||||
export function readSessionRevokedFlag() {
|
||||
try { return sessionStorage.getItem(REVOKED_FLAG) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
export function readSessionExpiredFlag() {
|
||||
@@ -39,5 +47,5 @@ export function readSessionExpiredFlag() {
|
||||
}
|
||||
|
||||
export function clearSessionExpiredFlag() {
|
||||
try { sessionStorage.removeItem(EXPIRED_FLAG); } catch { /* nada */ }
|
||||
try { sessionStorage.removeItem(EXPIRED_FLAG); sessionStorage.removeItem(REVOKED_FLAG); } catch { /* nada */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user