Login: evitar pantalla en blanco al arrancar
- ErrorBoundary global: si un modulo falla al renderizar se muestra un mensaje amable con recargar en lugar de pantalla en blanco. - El bootstrap de auth ya no se queda cargando para siempre: si /auth/me no responde en 20 s (red lenta o tunel caido) se muestra una pantalla de reintento. Si la red fallo se conserva la sesion; solo se descarta el token cuando el servidor contesta (401).
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { ToastProvider } from './components/ui';
|
import { ToastProvider, Button } from './components/ui';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import { AuthProvider, useAuth } from './lib/auth';
|
import { AuthProvider, useAuth } from './lib/auth';
|
||||||
import { QuickLinksProvider } from './lib/quicklinks';
|
import { QuickLinksProvider } from './lib/quicklinks';
|
||||||
import { PatientDrawerProvider } from './components/PatientDrawer';
|
import { PatientDrawerProvider } from './components/PatientDrawer';
|
||||||
@@ -36,9 +37,24 @@ const FullScreenLoader: FC = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Pantalla cuando el servidor no responde al arrancar (red lenta o caída):
|
||||||
|
// en lugar de quedarse cargando para siempre, ofrece reintentar.
|
||||||
|
const BootErrorScreen: FC<{ onRetry: () => void }> = ({ onRetry }) => (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6 bg-theme-bg">
|
||||||
|
<div className="max-w-md w-full space-y-4 rounded-2xl border border-theme-border bg-theme-surface p-8 shadow-soft text-center">
|
||||||
|
<h1 className="font-heading text-xl text-theme-heading">No se pudo conectar</h1>
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
El servidor no responde en este momento. Revisa tu conexión a internet e intenta de nuevo.
|
||||||
|
</p>
|
||||||
|
<Button onClick={onRetry}>Reintentar</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => {
|
const RequireAuth: FC<{ children: ReactNode; href: string }> = ({ children, href }) => {
|
||||||
const { user, loading, canSee } = useAuth();
|
const { user, loading, bootError, retryBoot, canSee } = useAuth();
|
||||||
if (loading) return <FullScreenLoader />;
|
if (loading) return <FullScreenLoader />;
|
||||||
|
if (bootError) return <BootErrorScreen onRetry={retryBoot} />;
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
// La ruta "/" se maneja en HomeRoute (calcula landing por rol) para evitar
|
// La ruta "/" se maneja en HomeRoute (calcula landing por rol) para evitar
|
||||||
// el bucle de redirecciones cuando el rol no tiene '/' en la matriz.
|
// el bucle de redirecciones cuando el rol no tiene '/' en la matriz.
|
||||||
@@ -81,6 +97,7 @@ const HomeRoute: FC = () => {
|
|||||||
|
|
||||||
const App: FC = () => {
|
const App: FC = () => {
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<QuickLinksProvider>
|
<QuickLinksProvider>
|
||||||
<Router>
|
<Router>
|
||||||
@@ -121,6 +138,7 @@ const App: FC = () => {
|
|||||||
</Router>
|
</Router>
|
||||||
</QuickLinksProvider>
|
</QuickLinksProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
48
frontend/src/components/ErrorBoundary.tsx
Normal file
48
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
import { Button } from './ui';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barrera de errores global: si un módulo lanza una excepción al renderizar,
|
||||||
|
* en lugar de la pantalla en blanco se muestra un mensaje amable con la
|
||||||
|
* opción de recargar. No sustituye corregir el error, pero evita que un bug
|
||||||
|
* puntual deje a recepción sin sistema.
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
// Log técnico en consola para depuración; al usuario solo se le muestra
|
||||||
|
// el mensaje amable de abajo.
|
||||||
|
console.error('Error de render capturado:', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-6 bg-theme-bg">
|
||||||
|
<div className="max-w-md w-full space-y-4 rounded-2xl border border-theme-border bg-theme-surface p-8 shadow-soft text-center">
|
||||||
|
<h1 className="font-heading text-xl text-theme-heading">Algo salió mal</h1>
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Ocurrió un error inesperado en la aplicación. Recarga la página; si el problema
|
||||||
|
continúa, cierra sesión y vuelve a entrar.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>Recargar la página</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -59,10 +59,18 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
|||||||
'/chat': ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'lectura'],
|
'/chat': ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'lectura'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Si /auth/me no responde en este tiempo (red lenta o caída), se muestra la
|
||||||
|
// pantalla de reintento en lugar del spinner eterno ("pantalla en blanco").
|
||||||
|
const BOOT_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: FrontendUser | null;
|
user: FrontendUser | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
// Error de arranque (servidor no responde): la app muestra Reintentar
|
||||||
|
// en lugar de quedarse cargando para siempre.
|
||||||
|
bootError: string | null;
|
||||||
|
retryBoot: () => void;
|
||||||
login: (login: string, password: string) => Promise<FrontendUser>;
|
login: (login: string, password: string) => Promise<FrontendUser>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
hasRole: (min: FrontendRole) => boolean;
|
hasRole: (min: FrontendRole) => boolean;
|
||||||
@@ -75,6 +83,9 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||||||
const [user, setUser] = useState<FrontendUser | null>(null);
|
const [user, setUser] = useState<FrontendUser | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
|
// Cada reintento de arranque re-ejecuta la validación del token.
|
||||||
|
const [bootAttempt, setBootAttempt] = useState(0);
|
||||||
// Matriz dinámica de permisos por rol (cache en localStorage; fallback a MENU_ROLES)
|
// Matriz dinámica de permisos por rol (cache en localStorage; fallback a MENU_ROLES)
|
||||||
const [menuRoles, setMenuRoles] = useState<Record<string, FrontendRole[]> | null>(() => {
|
const [menuRoles, setMenuRoles] = useState<Record<string, FrontendRole[]> | null>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -109,6 +120,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||||||
// Validar token al arrancar
|
// Validar token al arrancar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
let timer: number | undefined;
|
||||||
const t = localStorage.getItem(TOKEN_KEY);
|
const t = localStorage.getItem(TOKEN_KEY);
|
||||||
if (!t) {
|
if (!t) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -119,6 +131,13 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||||||
if (cached) {
|
if (cached) {
|
||||||
try { setUser(JSON.parse(cached)); } catch { /* ignore */ }
|
try { setUser(JSON.parse(cached)); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
// Red de seguridad: si el servidor no responde (red lenta/caída del
|
||||||
|
// túnel), mostrar pantalla de reintento en vez del spinner eterno.
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
if (!alive) return;
|
||||||
|
setBootError('El servidor no responde. Revisa tu conexión e intenta de nuevo.');
|
||||||
|
setLoading(false);
|
||||||
|
}, BOOT_TIMEOUT_MS);
|
||||||
odooApi.me()
|
odooApi.me()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
@@ -129,11 +148,30 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||||||
} else {
|
} else {
|
||||||
clear();
|
clear();
|
||||||
}
|
}
|
||||||
|
setBootError(null);
|
||||||
})
|
})
|
||||||
.catch(() => { if (alive) clear(); })
|
.catch((err) => {
|
||||||
.finally(() => { if (alive) setLoading(false); });
|
if (!alive) return;
|
||||||
return () => { alive = false; };
|
// Solo se descarta la sesión si el servidor contestó (token inválido,
|
||||||
}, [clear, refreshMenuRoles]);
|
// 401). Si la red falló se conserva el token para el reintento.
|
||||||
|
if (err?.response) clear();
|
||||||
|
else setBootError('No se pudo conectar con el servidor. Revisa tu conexión e intenta de nuevo.');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [clear, refreshMenuRoles, bootAttempt]);
|
||||||
|
|
||||||
|
const retryBoot = useCallback(() => {
|
||||||
|
setBootError(null);
|
||||||
|
setLoading(true);
|
||||||
|
setBootAttempt((n) => n + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const login = async (login: string, password: string): Promise<FrontendUser> => {
|
const login = async (login: string, password: string): Promise<FrontendUser> => {
|
||||||
const res = await odooApi.login(login, password);
|
const res = await odooApi.login(login, password);
|
||||||
@@ -182,7 +220,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, token, loading, login, logout, hasRole, canSee }}>
|
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user