From e4f985e6aa010cbf6fbd6c79a6b0a42cb63a06ec Mon Sep 17 00:00:00 2001 From: igferne Date: Wed, 7 Jan 2026 13:03:10 +0100 Subject: [PATCH] =?UTF-8?q?p=C3=A1gina=20de=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/beyond_api/api/auth.py | 26 ++++ backend/beyond_api/main.py | 5 +- frontend/App.tsx | 26 +++- frontend/components/LoginPage.tsx | 109 +++++++++++++++++ .../SinglePageDataRequestIntegrated.tsx | 29 ++++- frontend/utils/AuthContext.tsx | 111 ++++++++++++++++++ frontend/utils/analysisGenerator.ts | 17 ++- frontend/utils/apiClient.ts | 36 +++--- 8 files changed, 333 insertions(+), 26 deletions(-) create mode 100644 backend/beyond_api/api/auth.py create mode 100644 frontend/components/LoginPage.tsx create mode 100644 frontend/utils/AuthContext.tsx diff --git a/backend/beyond_api/api/auth.py b/backend/beyond_api/api/auth.py new file mode 100644 index 0000000..60ab56d --- /dev/null +++ b/backend/beyond_api/api/auth.py @@ -0,0 +1,26 @@ +# beyond_api/api/auth.py +from __future__ import annotations + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse + +from beyond_api.security import get_current_user + +router = APIRouter( + prefix="/auth", + tags=["auth"], +) + + +@router.get("/check") +def check_auth(current_user: str = Depends(get_current_user)): + """ + Endpoint muy simple: si las credenciales Basic son correctas, + devuelve 200 con el usuario. Si no, get_current_user lanza 401. + """ + return JSONResponse( + content={ + "user": current_user, + "status": "ok", + } + ) diff --git a/backend/beyond_api/main.py b/backend/beyond_api/main.py index 126350c..5306186 100644 --- a/backend/beyond_api/main.py +++ b/backend/beyond_api/main.py @@ -2,9 +2,9 @@ import logging from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware - # importa tus routers from beyond_api.api.analysis import router as analysis_router +from beyond_api.api.auth import router as auth_router # 👈 nuevo def setup_basic_logging() -> None: logging.basicConfig( @@ -29,4 +29,5 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router(analysis_router) \ No newline at end of file +app.include_router(analysis_router) +app.include_router(auth_router) # 👈 registrar el router de auth diff --git a/frontend/App.tsx b/frontend/App.tsx index 6ea7d05..b67da01 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,12 +1,32 @@ +// App.tsx import React from 'react'; +import { Toaster } from 'react-hot-toast'; import SinglePageDataRequestIntegrated from './components/SinglePageDataRequestIntegrated'; +import { AuthProvider, useAuth } from './utils/AuthContext'; +import LoginPage from './components/LoginPage'; + +const AppContent: React.FC = () => { + const { isAuthenticated } = useAuth(); + + return ( + <> + {isAuthenticated ? ( + + ) : ( + + )} + + ); +}; const App: React.FC = () => { return ( -
- -
+ + + + ); }; export default App; + diff --git a/frontend/components/LoginPage.tsx b/frontend/components/LoginPage.tsx new file mode 100644 index 0000000..94931e9 --- /dev/null +++ b/frontend/components/LoginPage.tsx @@ -0,0 +1,109 @@ +// components/LoginPage.tsx +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { Lock, User } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { useAuth } from '../utils/AuthContext'; + +const LoginPage: React.FC = () => { + const { login } = useAuth(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!username || !password) { + toast.error('Introduce usuario y contraseña'); + return; + } + + setIsSubmitting(true); + try { + await login(username, password); + toast.success('Sesión iniciada'); + } catch (err) { + console.error('Error en login', err); + const msg = + err instanceof Error ? err.message : 'Error al iniciar sesión'; + toast.error(msg); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ +
+
+ +
+

+ Beyond Diagnostic +

+

+ Inicia sesión para acceder al análisis +

+
+ +
+
+ +
+ + + + setUsername(e.target.value)} + /> +
+
+ +
+ +
+ + + + setPassword(e.target.value)} + /> +
+
+ + + +

+ La sesión permanecerá activa durante 1 hora. +

+
+
+
+ ); +}; + +export default LoginPage; diff --git a/frontend/components/SinglePageDataRequestIntegrated.tsx b/frontend/components/SinglePageDataRequestIntegrated.tsx index 32103fd..56a19f3 100644 --- a/frontend/components/SinglePageDataRequestIntegrated.tsx +++ b/frontend/components/SinglePageDataRequestIntegrated.tsx @@ -10,6 +10,7 @@ import DataInputRedesigned from './DataInputRedesigned'; import DashboardReorganized from './DashboardReorganized'; import { generateAnalysis } from '../utils/analysisGenerator'; import toast from 'react-hot-toast'; +import { useAuth } from '../utils/AuthContext'; const SinglePageDataRequestIntegrated: React.FC = () => { const [selectedTier, setSelectedTier] = useState('silver'); @@ -21,6 +22,9 @@ const SinglePageDataRequestIntegrated: React.FC = () => { setSelectedTier(tier); }; + const { authHeader, logout } = useAuth(); + + const handleAnalyze = (config: { costPerHour: number; avgCsat: number; @@ -44,6 +48,12 @@ const SinglePageDataRequestIntegrated: React.FC = () => { toast.error('Por favor, sube un archivo, introduce una URL o genera datos sintéticos.'); return; } + + // 🔐 Si usamos CSV real, exigir estar logado + if (config.file && !config.useSynthetic && !authHeader) { + toast.error('Debes iniciar sesión para analizar datos reales.'); + return; + } setIsAnalyzing(true); toast.loading('Generando análisis...', { id: 'analyzing' }); @@ -58,7 +68,8 @@ const SinglePageDataRequestIntegrated: React.FC = () => { config.segmentMapping, config.file, config.sheetUrl, - config.useSynthetic + config.useSynthetic, + authHeader || undefined ); console.log('✅ Analysis generated successfully'); @@ -74,7 +85,15 @@ const SinglePageDataRequestIntegrated: React.FC = () => { console.error('❌ Error generating analysis:', error); setIsAnalyzing(false); toast.dismiss('analyzing'); - toast.error('Error al generar el análisis: ' + (error as Error).message); + + const msg = (error as Error).message || ''; + + if (msg.includes('401')) { + toast.error('Sesión caducada o credenciales incorrectas. Vuelve a iniciar sesión.'); + logout(); + } else { + toast.error('Error al generar el análisis: ' + msg); + } } }, 1500); }; @@ -131,6 +150,12 @@ const SinglePageDataRequestIntegrated: React.FC = () => {

Análisis de Readiness Agéntico para Contact Centers

+ {/* Tier Selection */} diff --git a/frontend/utils/AuthContext.tsx b/frontend/utils/AuthContext.tsx new file mode 100644 index 0000000..f2eca62 --- /dev/null +++ b/frontend/utils/AuthContext.tsx @@ -0,0 +1,111 @@ +// utils/AuthContext.tsx +import React, { createContext, useContext, useEffect, useState } from 'react'; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'; + +type AuthContextValue = { + authHeader: string | null; + isAuthenticated: boolean; + login: (username: string, password: string) => Promise; // 👈 async + logout: () => void; +}; + +const AuthContext = createContext(undefined); + +const STORAGE_KEY = 'bd_auth_v1'; +const SESSION_DURATION_MS = 60 * 60 * 1000; // 1 hora + +type StoredAuth = { + authHeader: string; + expiresAt: number; +}; + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [authHeader, setAuthHeader] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); + + useEffect(() => { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed: StoredAuth = JSON.parse(raw); + if (parsed.authHeader && parsed.expiresAt && parsed.expiresAt > Date.now()) { + setAuthHeader(parsed.authHeader); + setExpiresAt(parsed.expiresAt); + } else { + window.localStorage.removeItem(STORAGE_KEY); + } + } catch (err) { + console.error('Error leyendo auth de localStorage', err); + } + }, []); + + const logout = () => { + setAuthHeader(null); + setExpiresAt(null); + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + /* no-op */ + } + }; + + const login = async (username: string, password: string): Promise => { + const basic = 'Basic ' + btoa(`${username}:${password}`); + + // 1) Validar contra /auth/check + let resp: Response; + try { + resp = await fetch(`${API_BASE_URL}/auth/check`, { + method: 'GET', + headers: { + Authorization: basic, + }, + }); + } catch (err) { + console.error('Error llamando a /auth/check', err); + throw new Error('No se ha podido contactar con el servidor.'); + } + + if (resp.status === 401) { + throw new Error('Credenciales inválidas'); + } + + if (!resp.ok) { + throw new Error(`No se ha podido validar las credenciales (status ${resp.status}).`); + } + + // 2) Si hemos llegado aquí, las credenciales son válidas -> guardamos sesión + const exp = Date.now() + SESSION_DURATION_MS; + + setAuthHeader(basic); + setExpiresAt(exp); + + try { + const toStore: StoredAuth = { authHeader: basic, expiresAt: exp }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + } catch (err) { + console.error('Error guardando auth en localStorage', err); + } + }; + + const isAuthenticated = !!authHeader && !!expiresAt && expiresAt > Date.now(); + + const value: AuthContextValue = { + authHeader: isAuthenticated ? authHeader : null, + isAuthenticated, + login, + logout, + }; + + return {children}; +}; + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth debe usarse dentro de un AuthProvider'); + } + return ctx; +} diff --git a/frontend/utils/analysisGenerator.ts b/frontend/utils/analysisGenerator.ts index c61c486..ad1afe3 100644 --- a/frontend/utils/analysisGenerator.ts +++ b/frontend/utils/analysisGenerator.ts @@ -905,7 +905,8 @@ export const generateAnalysis = async ( segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }, file?: File, sheetUrl?: string, - useSynthetic?: boolean + useSynthetic?: boolean, + authHeaderOverride?: string ): Promise => { // Si hay archivo, procesarlo // Si hay archivo, primero intentamos usar el backend @@ -920,6 +921,7 @@ export const generateAnalysis = async ( avgCsat, segmentMapping, file, + authHeaderOverride, }); const mapped = mapBackendResultsToAnalysisData(raw, tier); @@ -952,7 +954,18 @@ export const generateAnalysis = async ( return mapped; - } catch (apiError) { + } catch (apiError: any) { + const status = apiError?.status; + const msg = (apiError as Error).message || ''; + + // 🔐 Si es un error de autenticación (401), NO hacemos fallback + if (status === 401 || msg.includes('401')) { + console.error( + '❌ Error de autenticación en backend, abortando análisis (sin fallback).' + ); + throw apiError; + } + console.error( '❌ Backend /analysis no disponible o mapeo incompleto, fallback a lógica local:', apiError diff --git a/frontend/utils/apiClient.ts b/frontend/utils/apiClient.ts index 70ca427..03d7dcb 100644 --- a/frontend/utils/apiClient.ts +++ b/frontend/utils/apiClient.ts @@ -36,8 +36,9 @@ export async function callAnalysisApiRaw(params: { avgCsat: number; segmentMapping?: SegmentMapping; file: File; + authHeaderOverride?: string; }): Promise { - const { costPerHour, segmentMapping, file } = params; + const { costPerHour, segmentMapping, file, authHeaderOverride } = params; if (!file) { throw new Error('No se ha proporcionado ningún archivo CSV'); @@ -73,31 +74,32 @@ export async function callAnalysisApiRaw(params: { formData.append('economy_json', JSON.stringify(economyData)); } + // Si nos pasan un Authorization desde el login, lo usamos. + // Si no, caemos al getAuthHeader() basado en variables de entorno (útil en dev). + const authHeaders: Record = authHeaderOverride + ? { Authorization: authHeaderOverride } + : getAuthHeader(); + const response = await fetch(`${API_BASE_URL}/analysis`, { method: 'POST', body: formData, headers: { - ...getAuthHeader(), + ...authHeaders, }, }); if (!response.ok) { - let errorText = `Error API (${response.status})`; - try { - const errorBody = await response.json(); - if (errorBody?.detail) { - errorText = String(errorBody.detail); - } - } catch { - // ignoramos si no es JSON - } - throw new Error(errorText); + const error = new Error( + `Error en API /analysis: ${response.status} ${response.statusText}` + ); + (error as any).status = response.status; + throw error; } - const data = await response.json(); - const rawResults = data.results ?? data; + // ⬇️ IMPORTANTE: nos quedamos solo con `results` + const json = await response.json(); + const results = (json as any)?.results ?? json; - console.debug('🔍 Backend /analysis raw results:', rawResults); - - return rawResults; + return results as BackendRawResults; } +