diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..2b0a539 --- /dev/null +++ b/.env.template @@ -0,0 +1,11 @@ +# Keycloak Configuration +KEYCLOAK_URL=http://localhost:8080 +KEYCLOAK_REALM=angular-app +KEYCLOAK_CLIENT_ID=angular-app +KEYCLOAK_CLIENT_SECRET=your-client-secret + +# API Configuration +API_BASE_URL=/api + +# Environment +NODE_ENV=development \ No newline at end of file diff --git a/README.md b/README.md index dfc1394..4f79038 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,22 @@ -# Cronogramas PrimeNG Application +# SACG - Sistema Administrador de Cronogramas -Este proyecto es una aplicación Angular 19 utilizando PrimeNG para la gestión de cronogramas de concesiones. +Aplicación Angular 19 con PrimeNG para la gestión de cronogramas de concesiones, utilizando autenticación directa con Keycloak. + +## Características Principales + +- Autenticación DirectAuth con Keycloak (implementación moderna sin usar keycloak-angular) +- Componentes Standalone Angular 19 +- Interfaz de usuario con PrimeNG +- Gestión de cronogramas y concesiones +- Exportación de datos a PDF y Excel ## Instalación ### Requisitos Previos -- Node.js (versión 20.x recomendada) +- Node.js (versión 20.x o superior) - npm (incluido con Node.js) -- Git +- Keycloak Server (para autenticación) ### Pasos de Instalación @@ -16,18 +24,40 @@ Este proyecto es una aplicación Angular 19 utilizando PrimeNG para la gestión ```bash git clone - cd cronogramas-primeng + cd sacg-cronogramas ``` -2. **Instalar Dependencias** +2. **Configurar Variables de Entorno** + + Copie el archivo de plantilla de entorno y configúrelo para su entorno: + + ```bash + cp .env.template .env + ``` + + Edite el archivo `.env` con la configuración de su entorno de Keycloak: + + ```env + # Keycloak Configuration + KEYCLOAK_URL=http://localhost:8080 + KEYCLOAK_REALM=angular-app + KEYCLOAK_CLIENT_ID=angular-app + KEYCLOAK_CLIENT_SECRET=your-client-secret + + # API Configuration + API_BASE_URL=/api + + # Environment + NODE_ENV=development + ``` + +3. **Instalar Dependencias** ```bash npm install ``` - > **Importante**: Durante la instalación, se ejecutará automáticamente el script `setup-project.js` (definido como `postinstall` en package.json) que solicitará el nombre del nuevo proyecto para personalizar la configuración. - -3. **Iniciar el Servidor de Desarrollo** +4. **Iniciar el Servidor de Desarrollo** ```bash npm start @@ -35,284 +65,127 @@ Este proyecto es una aplicación Angular 19 utilizando PrimeNG para la gestión La aplicación estará disponible en `http://localhost:4200/` -## Dependencias Principales +## Configuración de Keycloak -El proyecto utiliza las siguientes bibliotecas clave: +### Configuración del Servidor -- **Angular 19**: Framework base - ```bash - npm install @angular/core @angular/common @angular/forms @angular/router @angular/compiler - ``` +1. **Instalar y Ejecutar Keycloak**: + - Descargue Keycloak desde [keycloak.org](https://www.keycloak.org/downloads) + - Inicie el servidor: `bin/kc.[sh|bat] start-dev` + - Acceda a la consola de administración: `http://localhost:8080/admin` -- **PrimeNG 19.1.0**: Biblioteca de componentes UI - ```bash - npm install primeng @primeng/themes - ``` +2. **Crear un Realm**: + - Cree un nuevo realm llamado `angular-app` + - Este nombre debe coincidir con su variable `KEYCLOAK_REALM` en `.env` -- **PrimeFlex 4.0.0**: Sistema de CSS flexible - ```bash - npm install primeflex - ``` +3. **Crear un Cliente**: + - Cree un nuevo cliente con ID `angular-app` (debe coincidir con `KEYCLOAK_CLIENT_ID`) + - Configure: + - Access Type: `confidential` o `public` (según su caso) + - Valid Redirect URIs: `http://localhost:4200/*` + - Web Origins: `http://localhost:4200` (o agregar `+` para permitir cualquier origen) -- **PrimeIcons 7.0.0**: Conjunto de iconos - ```bash - npm install primeicons - ``` +4. **Configurar Usuarios y Roles**: + - Cree usuarios para pruebas + - Configure roles como `admin`, `user`, etc. + - Asigne roles a los usuarios -- **PDFMake 0.2.19**: Para generación de PDFs - ```bash - npm install pdfmake @types/pdfmake - ``` +### Integración con la Aplicación -- **ExcelJS 4.4.0**: Para exportación a Excel - ```bash - npm install exceljs - ``` +La aplicación utiliza una implementación moderna de autenticación directa con Keycloak, sin depender del paquete keycloak-angular que está deprecado. En su lugar: -- **File-Saver 2.5.0**: Para guardar archivos en el cliente - ```bash - npm install file-saver @types/file-saver - ``` +- Utiliza el protocolo OpenID Connect para autenticación directa +- Gestiona tokens manualmente para mayor control +- Implementa renovación automática de tokens +- Provee detección de inactividad de usuario -- **Animate.css 4.1.1**: Para animaciones CSS - ```bash - npm install animate.css - ``` +## Entornos de Ejecución -## Configuración del Proyecto +Los archivos de entorno están configurados en `src/environments/`: -### Script de Configuración (setup-project.js) +- **environment.ts**: Configuración para desarrollo local +- **environment.prod.ts**: Configuración para producción -El proyecto incluye un script de configuración post-instalación (`setup-project.js`) que se ejecuta automáticamente después de `npm install`. Este script realiza las siguientes tareas: +Las variables de entorno relacionadas con Keycloak se definen en estos archivos y se cargan desde los valores configurados en `.env`. -1. Solicita el nombre del nuevo proyecto -2. Actualiza package.json con el nuevo nombre -3. Modifica angular.json para actualizar todas las referencias al nombre del proyecto -4. Actualiza el título en src/index.html +## Flujo de Autenticación -Este script facilita la reutilización del template para crear nuevos proyectos basados en esta estructura. +1. **Login**: + - Usuario ingresa credenciales en la pantalla de login + - La aplicación realiza una solicitud directa al endpoint de token de Keycloak + - Después de una autenticación exitosa, se almacena el token JWT -### Configuración de Estilos en angular.json +2. **Manejo de Tokens**: + - El token se almacena en localStorage para persistencia entre sesiones + - Se configura un temporizador para renovación automática del token + - Se decodifica el token para extraer información del usuario -El proyecto está configurado para utilizar varios estilos externos a través del archivo `angular.json`: +3. **Protección de Rutas**: + - Las rutas protegidas utilizan el guardia `authGuard` + - La verificación se basa en la validez del token almacenado -```json -"styles": [ - "src/styles.scss", - { - "input": "node_modules/animate.css/animate.min.css", - "bundleName": "animate", - "inject": true - }, - { - "input": "node_modules/primeflex/primeflex.css", - "bundleName": "primeflex", - "inject": true - }, - { - "input": "node_modules/primeicons/primeicons.css", - "bundleName": "primeicons", - "inject": true - } -] -``` - -Para añadir nuevos estilos externos, sigue el mismo patrón en el archivo angular.json. - -## Estructura del Proyecto - -``` -/cronogramas-primeng/ -├── src/ # Código fuente -│ ├── app/ # Componentes Angular -│ │ ├── components/ # Componentes compartidos -│ │ │ ├── alert-dialog/ # Diálogo de alertas -│ │ │ ├── footer/ # Pie de página -│ │ │ ├── layout/ # Estructura principal -│ │ │ ├── navbar/ # Barra de navegación -│ │ │ ├── sidebar/ # Barra lateral -│ │ │ └── visor-pdf/ # Visualizador de PDF -│ │ ├── guards/ # Guardias de ruta -│ │ ├── interceptors/ # Interceptores HTTP -│ │ ├── models/ # Interfaces de datos -│ │ ├── pages/ # Componentes de página -│ │ ├── services/ # Servicios -│ │ └── utils/ # Utilidades -│ ├── pipes/ # Pipes personalizados -│ ├── index.html # HTML principal -│ ├── main.ts # Punto de entrada -│ └── styles.scss # Estilos globales -├── public/ # Recursos estáticos -├── angular.json # Configuración de Angular -├── package.json # Dependencias y scripts -├── setup-project.js # Script de configuración post-instalación -├── tsconfig.json # Configuración TypeScript -└── sonar-project.properties # Configuración para SonarQube -``` - -## Modelos de Datos (Interfaces) - -El proyecto define las siguientes interfaces para los modelos de datos: - -- **Cronograma**: Modelo base para todos los cronogramas -- **Empresa**: Modelo para empresas -- **TipoCarga**: Modelo para tipos de carga -- **EstadoAprobacion**: Modelo para estados de aprobación -- **ActualizacionPd**: Modelo para actualizaciones de Plan de Desarrollo -- **AjustePd**: Modelo para ajustes de Plan de Desarrollo -- **UnidadInformacion**: Modelo para unidades de información - -## Servicios - -Los servicios implementados permiten conectarse a un backend mediante HTTP: - -- **CronogramaService**: CRUD para cronogramas -- **EmpresaService**: CRUD para empresas -- **ActualizacionPdService**: CRUD para actualizaciones de PD -- **AjustePdService**: CRUD para ajustes de PD -- **UnidadInformacionService**: CRUD para unidades de información -- **TipoCargaService**: Consulta de tipos de carga -- **EstadoAprobacionService**: Consulta de estados de aprobación -- **AuthService**: Autenticación y gestión de tokens -- **PdfService**: Generación y manejo de PDFs -- **AlertService**: Gestión de alertas - -## Generación de PDFs con PDFMake - -El proyecto utiliza PDFMake para la generación dinámica de PDFs. La configuración básica se realiza así: - -1. **Importar pdfMake en el servicio**: - -```typescript -import pdfMake from 'pdfmake/build/pdfmake'; -import pdfFonts from 'pdfmake/build/vfs_fonts'; - -pdfMake.vfs = pdfFonts.vfs; -``` - -2. **Definir el documento PDF** usando la estructura de pdfMake: - -```typescript -const docDefinition = { - content: [ - { text: 'Título del Documento', style: 'header' }, - // Contenido del documento - ], - styles: { - header: { - fontSize: 18, - bold: true - } - } -}; -``` - -3. **Generar o descargar el PDF**: - -```typescript -// Generar como URL de datos -const pdfDocGenerator = pdfMake.createPdf(docDefinition); -pdfDocGenerator.getDataUrl((dataUrl) => { - // Usar dataUrl para mostrar el PDF en un iframe -}); - -// Descargar el PDF -pdfMake.createPdf(docDefinition).download('nombre-archivo.pdf'); -``` - -4. **Visualizar el PDF** usando el componente `visor-pdf`: - -```typescript -// En el componente que necesita mostrar el PDF -import { DialogService } from 'primeng/dynamicdialog'; -import { VisorPdfComponent } from 'path/to/visor-pdf.component'; - -constructor(private dialogService: DialogService) {} - -mostrarPDF() { - this.dialogService.open(VisorPdfComponent, { - header: 'Cronograma PDF', - width: '80%', - data: { - product: this.item // Datos para el PDF - } - }); -} -``` - -## Seguridad - -La aplicación incluye: - -- **AuthInterceptor**: Interceptor HTTP para añadir tokens de autenticación a las solicitudes -- **AuthGuard**: Guard para proteger rutas que requieren autenticación -- **Login**: Componente para autenticación de usuarios - -## Animaciones de Ruta - -El proyecto implementa animaciones de transición entre rutas usando: - -- **RouteAnimationsComponent**: Define las animaciones para las transiciones de ruta -- **Animate.css**: Proporciona clases CSS predefinidas para animaciones - -## Componentes Principales - -### Layout Component - -Define la estructura principal de la aplicación, incluyendo: -- Barra de navegación (navbar) -- Barra lateral (sidebar) -- Contenido principal con soporte para animaciones de ruta -- Pie de página (footer) - -### VisorPDF Component - -Componente standalone que permite: -- Visualizar PDFs generados dinámicamente -- Descargar el PDF visualizado -- Enviar el PDF a través del sistema +4. **Interceptor HTTP**: + - Todas las solicitudes HTTP incluyen automáticamente el token de autenticación + - En caso de error 401, se intenta renovar el token automáticamente ## Comandos Disponibles | Comando | Descripción | |---------|-------------| -| `npm start` | Inicia servidor de desarrollo | -| `npm run build` | Compila el proyecto | -| `npm run build:prod` | Compila para producción | -| `npm run watch` | Compila en modo observador | +| `npm start` | Inicia servidor de desarrollo en http://localhost:4200 | +| `npm run build` | Compila el proyecto para desarrollo | +| `npm run build:prod` | Compila para producción con optimizaciones | | `npm test` | Ejecuta pruebas unitarias | -## Extendiendo el Proyecto +## Estructura del Proyecto -Para extender este proyecto como base para un nuevo desarrollo: +``` +/sacg-cronogramas/ +├── src/ +│ ├── app/ +│ │ ├── components/ # Componentes compartidos +│ │ ├── guards/ # Guardias de ruta (authGuard) +│ │ ├── interceptors/ # Interceptores HTTP (authInterceptor) +│ │ ├── models/ # Interfaces de datos +│ │ ├── pages/ # Componentes de página +│ │ ├── services/ +│ │ │ ├── direct-auth.service.ts # Implementación DirectAuth +│ │ │ └── ... # Otros servicios +│ │ └── utils/ # Utilidades +│ ├── environments/ # Configuración de entornos +│ │ ├── environment.ts # Desarrollo +│ │ └── environment.prod.ts # Producción +│ ├── pipes/ # Pipes personalizados +│ ├── index.html # HTML principal +│ ├── main.ts # Punto de entrada +│ └── styles.scss # Estilos globales +├── public/ # Recursos estáticos +│ └── keycloak.json # Configuración de Keycloak +├── .env.template # Plantilla para variables de entorno +├── angular.json # Configuración de Angular +├── package.json # Dependencias y scripts +└── README.md # Esta documentación +``` -1. Clona el repositorio -2. Ejecuta `npm install` (esto iniciará automáticamente el script setup-project.js) -3. Proporciona el nombre del nuevo proyecto cuando se te solicite -4. El script actualizará automáticamente la configuración con el nuevo nombre -5. Comienza a desarrollar tu aplicación personalizada +## Solución de Problemas -## Buenas Prácticas +### Problemas Comunes de Autenticación -1. **Organización de Componentes**: - - Componentes de página en la carpeta `pages/` - - Componentes reutilizables en `components/` +1. **No se puede iniciar sesión**: + - Verifique que las credenciales sean correctas + - Asegúrese de que el servidor Keycloak esté ejecutándose + - Compruebe que el realm y client ID en `.env` coincidan con su configuración de Keycloak -2. **Modelo de Datos**: - - Definir interfaces para todos los modelos en `models/` - - Exportar todas las interfaces desde `models/index.ts` +2. **Token expirado o inválido**: + - La aplicación debería renovar automáticamente el token + - Si persiste, intente cerrar sesión y volver a iniciar sesión -3. **Servicios**: - - Mantener la responsabilidad única para cada servicio - - Centralizar la lógica de negocio en los servicios - -4. **Gestión de Estado**: - - Utilizar servicios para compartir estado entre componentes +3. **Redirecciones en bucle**: + - Limpie el localStorage del navegador + - Verifique la configuración de URLs en el cliente Keycloak ## Recursos Adicionales -- [Documentación de Angular](https://angular.dev/) -- [Documentación de PrimeNG](https://primeng.org/installation) -- [Documentación de PrimeFlex](https://primeflex.org/) -- [Documentación de PDFMake](http://pdfmake.org/) -- [Documentación de ExcelJS](https://github.com/exceljs/exceljs) \ No newline at end of file +- [Documentación oficial de Keycloak](https://www.keycloak.org/documentation) +- [Angular Security Best Practices](https://angular.io/guide/security) +- [Tutorial completo de Keycloak](tutorial-keycloak-completo.md) \ No newline at end of file diff --git a/public/keycloak.json b/public/keycloak.json index 77f770e..4fa3d2c 100644 --- a/public/keycloak.json +++ b/public/keycloak.json @@ -1,6 +1,6 @@ { - "realm": "aangular-app", - "auth-server-url": "http://localhost:8080/", + "realm": "angular-app", + "auth-server-url": "http://192.168.1.27:8080/", "resource": "angular-app", "credentials": { "secret": "zYbODELDmLjK9c9gHNbTUe8mSZlcLFZm" diff --git a/src/app/app.component.ts b/src/app/app.component.ts index fea08b4..9a16239 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,65 +1,30 @@ -import { Component, inject, OnInit, OnDestroy, effect, Signal } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; -import { ConfirmationService } from 'primeng/api'; +import { ConfirmationService, MessageService } from 'primeng/api'; import { filter, Subscription } from 'rxjs'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import Keycloak from 'keycloak-js'; -import { AuthService } from './services/auth.service'; -import { KEYCLOAK_EVENT_SIGNAL, KeycloakEventType, KeycloakEvent } from 'keycloak-angular'; +import { ToastModule } from 'primeng/toast'; +import { DirectAuthService } from './services/direct-auth.service'; @Component({ selector: 'app-root', - imports: [RouterOutlet, ConfirmDialogModule], + imports: [RouterOutlet, ConfirmDialogModule, ToastModule], templateUrl: './app.component.html', styleUrl: './app.component.scss', - providers: [ConfirmationService], + providers: [ConfirmationService, MessageService], standalone: true, }) -export class AppComponent implements OnInit, OnDestroy { +export class AppComponent implements OnInit { title = 'SACG - Sistema Administrador de Cronogramas'; - private keycloak = inject(Keycloak); - private keycloakEvents = inject(KEYCLOAK_EVENT_SIGNAL); - private authService = inject(AuthService); + private authService = inject(DirectAuthService); private router = inject(Router); - + private subscriptions: Subscription[] = []; - + constructor() { - // Using effect to handle Keycloak events through Angular's Signal API - effect(() => { - const event = this.keycloakEvents(); - if (!event) return; - - console.log('Keycloak event received:', event.type); - - // Authentication success handling - if (event.type === KeycloakEventType.AuthSuccess) { - console.log('Authentication successful'); - // We'll let the guards and login component handle redirections - // No redirect here to avoid conflicts - } - - // Authentication error handling - if (event.type === KeycloakEventType.AuthError) { - console.error('Authentication error'); - // Only redirect to login if not already there - if (this.router.url !== '/login') { - this.router.navigate(['/login']); - } - } - - // Logout handling - if (event.type === KeycloakEventType.AuthLogout) { - console.log('Logged out'); - // Only redirect to login if not already there - if (this.router.url !== '/login') { - this.router.navigate(['/login']); - } - } - }); + // Ya no es necesario el efecto keycloak } - + ngOnInit() { // Subscribe to navigation events - using standard unsubscribe pattern this.subscriptions.push( @@ -71,7 +36,7 @@ export class AppComponent implements OnInit, OnDestroy { console.log('Navigation completed to:', this.router.url); }) ); - + // Check authentication status on load this.checkAuthenticationStatus(); } @@ -83,20 +48,20 @@ export class AppComponent implements OnInit, OnDestroy { private async checkAuthenticationStatus(): Promise { try { - const isLoggedIn = await this.keycloak.authenticated; + const isLoggedIn = this.authService.isAuthenticated(); console.log('Initial authentication status:', isLoggedIn ? 'Authenticated' : 'Not authenticated'); - + // Let the guards handle the protected routes // Only do minimal checks here to avoid redirect loops - + // If the user is on login page but already authenticated, send to home if (isLoggedIn && this.router.url === '/login') { console.log('Already authenticated, redirecting from login to home'); this.router.navigate(['/inicio']); return; } - - // If not authenticated and on a protected route, go to Keycloak login + + // If not authenticated and on a protected route, go to login if (!isLoggedIn && this.router.url !== '/login') { // We'll let the auth guard handle this console.log('Not authenticated on protected route'); @@ -105,4 +70,4 @@ export class AppComponent implements OnInit, OnDestroy { console.error('Error checking authentication status:', error); } } -} +} \ No newline at end of file diff --git a/src/app/app.config.ts b/src/app/app.config.ts index f5d20dd..9a866ac 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -6,25 +6,8 @@ import { provideAnimations } from '@angular/platform-browser/animations'; import { providePrimeNG } from 'primeng/config'; import Aura from '@primeng/themes/aura'; import { MessageService } from 'primeng/api'; -import { - provideKeycloak, - createInterceptorCondition, - IncludeBearerTokenCondition, - INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG, - includeBearerTokenInterceptor -} from 'keycloak-angular'; - -// Define condition for including the token in requests -const localhostCondition = createInterceptorCondition({ - urlPattern: /^(http:\/\/localhost)(\/.*)?$/i, // Match URLs starting with http://localhost - bearerPrefix: 'Bearer' -}); - -// Define another condition for API URLs -const apiCondition = createInterceptorCondition({ - urlPattern: /^(\/api)(\/.*)?$/i, // Match URLs starting with /api - bearerPrefix: 'Bearer' -}); +import { authInterceptor } from './interceptors/auth.interceptor'; +import { environment } from '../environments/environment'; export const appConfig: ApplicationConfig = { providers: [ @@ -34,11 +17,13 @@ export const appConfig: ApplicationConfig = { withPreloading(PreloadAllModules) ), provideAnimations(), - // Use the Keycloak interceptor to attach authentication tokens + + // Usamos nuestro interceptor personalizado para DirectAuthService provideHttpClient( withFetch(), - withInterceptors([includeBearerTokenInterceptor]) + withInterceptors([authInterceptor]) ), + providePrimeNG({ theme: { preset: Aura, @@ -47,27 +32,7 @@ export const appConfig: ApplicationConfig = { } } }), - MessageService, - // Provide Keycloak with initOptions - this automatically creates an APP_INITIALIZER - provideKeycloak({ - config: { - url: 'http://localhost:8080', - realm: 'angular-app', - clientId: 'angular-app', - }, - initOptions: { - onLoad: 'check-sso', // Cambiado de check-sso a login-required para forzar login - silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`, - checkLoginIframe: false, - pkceMethod: 'S256', - enableLogging: true - }, - providers: [ - { - provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG, - useValue: [localhostCondition, apiCondition] - } - ] - }) + + MessageService ] }; \ No newline at end of file diff --git a/src/app/components/navbar/navbar.component.ts b/src/app/components/navbar/navbar.component.ts index 713e70b..07f0f8d 100644 --- a/src/app/components/navbar/navbar.component.ts +++ b/src/app/components/navbar/navbar.component.ts @@ -7,7 +7,7 @@ import { filter, map, mergeMap } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { ToastModule } from 'primeng/toast'; -import { AuthService } from '../../services/auth.service'; +import { DirectAuthService } from '../../services/direct-auth.service'; @Component({ selector: 'app-navbar', @@ -33,7 +33,7 @@ export class NavbarComponent implements OnInit { private messageService: MessageService, private router: Router, private activatedRoute: ActivatedRoute, - private authService: AuthService + private authService: DirectAuthService ) { this.router.events.pipe( filter(event => event instanceof NavigationEnd), @@ -52,13 +52,12 @@ export class NavbarComponent implements OnInit { ngOnInit() { // Obtener nombre de usuario del usuario logueado - this.authService.user$.subscribe(user => { - if (user) { - this.userName = user.name || user.username || 'Usuario'; - } else { - this.userName = 'Usuario'; - } - }); + const user = this.authService.getCurrentUser(); + if (user) { + this.userName = user.name || user.username || 'Usuario'; + } else { + this.userName = 'Usuario'; + } } toggleSidebar() { @@ -92,4 +91,4 @@ export class NavbarComponent implements OnInit { }); this.router.navigate(['/login']); } -} +} \ No newline at end of file diff --git a/src/app/guards/auth.guard.ts b/src/app/guards/auth.guard.ts index eafe206..9843946 100644 --- a/src/app/guards/auth.guard.ts +++ b/src/app/guards/auth.guard.ts @@ -1,57 +1,23 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; -import Keycloak from 'keycloak-js'; import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { createAuthGuard, AuthGuardData } from 'keycloak-angular'; +import { DirectAuthService } from '../services/direct-auth.service'; -// Simple implementation for authentication guard -export const authGuard: CanActivateFn = async ( +// Simple implementation for authentication guard using DirectAuthService +export const authGuard: CanActivateFn = ( route: ActivatedRouteSnapshot, state: RouterStateSnapshot -): Promise => { - const keycloak = inject(Keycloak); +): boolean | UrlTree => { + const authService = inject(DirectAuthService); const router = inject(Router); - try { - // Check if user is authenticated - const authenticated = await keycloak.authenticated; - - if (authenticated) { - console.log('User is authenticated, allowing access to protected route'); - return true; - } - - // If not authenticated, redirect to login - console.log('User not authenticated, redirecting to login page'); - return router.createUrlTree(['/login'], { - queryParams: { returnUrl: state.url !== '/' ? state.url : '/inicio' } - }); - } catch (error) { - console.error('Error checking authentication:', error); - // Fallback to login on error - return router.createUrlTree(['/login']); - } -}; - -// Alternative implementation using the helper function from keycloak-angular -const isAccessAllowed = async ( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - authData: AuthGuardData -): Promise => { - const { authenticated } = authData; - - if (authenticated) { + // Check if user is authenticated + if (authService.isAuthenticated()) { return true; } - // Store the URL the user was trying to access - const returnUrl = state.url; - const router = inject(Router); - - // Redirect to login page with return URL - return router.createUrlTree(['/login'], { queryParams: { returnUrl } }); -}; - -// Optional: Use the createAuthGuard helper if needed -export const authGuardWithHelper = createAuthGuard(isAccessAllowed); \ No newline at end of file + // If not authenticated, redirect to login + return router.createUrlTree(['/login'], { + queryParams: { returnUrl: state.url !== '/' ? state.url : '/inicio' } + }); +}; \ No newline at end of file diff --git a/src/app/interceptors/auth.interceptor.ts b/src/app/interceptors/auth.interceptor.ts index 4f556ed..100d5ca 100644 --- a/src/app/interceptors/auth.interceptor.ts +++ b/src/app/interceptors/auth.interceptor.ts @@ -1,57 +1,53 @@ +import { HttpHandlerFn, HttpInterceptorFn, HttpRequest, HttpErrorResponse } from '@angular/common/http'; import { inject } from '@angular/core'; -import { - HttpRequest, - HttpHandlerFn, - HttpInterceptorFn, - HttpErrorResponse -} from '@angular/common/http'; -import { Observable, throwError, from, switchMap } from 'rxjs'; -import { catchError } from 'rxjs/operators'; -import Keycloak from 'keycloak-js'; -import { Router } from '@angular/router'; +import { catchError, switchMap, throwError } from 'rxjs'; +import { DirectAuthService } from '../services/direct-auth.service'; +import { environment } from '../../environments/environment'; -/** - * Note: This interceptor is not strictly necessary when using keycloak-angular's - * built-in includeBearerTokenInterceptor, which is configured in app.config.ts. - * It's included here to provide additional error handling functionality. - */ -export const authInterceptor: HttpInterceptorFn = ( - request: HttpRequest, - next: HttpHandlerFn -): Observable => { - const keycloak = inject(Keycloak); - const router = inject(Router); +export const authInterceptor: HttpInterceptorFn = (req: HttpRequest, next: HttpHandlerFn) => { + const authService = inject(DirectAuthService); - // Handle the request with error handling for auth issues - return next(request).pipe( - catchError((error: HttpErrorResponse) => { - // Handle 401 Unauthorized errors - if (error.status === 401) { - console.log('401 Unauthorized error, refreshing token or redirecting to login'); - - // Try to refresh the token first - return from(keycloak.updateToken(30)).pipe( - switchMap(refreshed => { - if (refreshed) { - // Token was refreshed, retry the request - return next(request); - } else { - // Token couldn't be refreshed, redirect to login - keycloak.login(); - return throwError(() => error); - } - }), - catchError(refreshError => { - console.error('Error refreshing token:', refreshError); - // Redirect to login in case of refresh error - router.navigate(['/login']); - return throwError(() => error); - }) - ); + // No interceptar peticiones al endpoint de token (evitar bucles) + if (req.url.includes('/protocol/openid-connect/token')) { + return next(req); + } + + // Agregar token de autenticación si está disponible + const token = authService.getToken(); + if (token) { + req = addToken(req, token); + } + + return next(req).pipe( + catchError(error => { + if (error instanceof HttpErrorResponse && error.status === 401) { + // Si es error 401, intentar refrescar token + return handle401Error(req, next, authService); } - - // For other errors, just pass them through return throwError(() => error); }) ); -}; \ No newline at end of file +}; + +// Función para agregar token a la petición +function addToken(request: HttpRequest, token: string): HttpRequest { + return request.clone({ + setHeaders: { + Authorization: `Bearer ${token}` + } + }); +} + +// Función para manejar errores 401 (token expirado) +function handle401Error(req: HttpRequest, next: HttpHandlerFn, authService: DirectAuthService) { + return authService.refreshToken().pipe( + switchMap((token: any) => { + return next(addToken(req, token.access_token)); + }), + catchError((err) => { + // Si falla la renovación, forzar cierre de sesión + authService.logout(); + return throwError(() => err); + }) + ); +} \ No newline at end of file diff --git a/src/app/pages/login/login.component.html b/src/app/pages/login/login.component.html index 4dd0c87..073bbc8 100644 --- a/src/app/pages/login/login.component.html +++ b/src/app/pages/login/login.component.html @@ -10,54 +10,102 @@ - -
- - -
+ + +

Iniciar Sesión

- -
- -
-

Haz clic en el botón para iniciar sesión con la cuenta de usuario registrada en el sistema.

+
+ +
+
- - + +
+ +
+ +
+ +
+
-
+
+ +
+ Recuperar Contraseña
- - +
-

Información:

-

Serás redirigido al sistema de autenticación.

-

Si tienes problemas, contacta al administrador.

+

Credenciales de prueba:

+

Email: admin@example.com

+

Password: admin123

+ + +
+
+
+

Recuperar Contraseña

+
+
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+
+
- \ No newline at end of file diff --git a/src/app/pages/login/login.component.ts b/src/app/pages/login/login.component.ts index ddc46e6..f9d9325 100644 --- a/src/app/pages/login/login.component.ts +++ b/src/app/pages/login/login.component.ts @@ -1,110 +1,161 @@ -import { Component, OnInit, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { Component, OnInit, signal } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; -import { CardModule } from 'primeng/card'; -import { InputTextModule } from 'primeng/inputtext'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DirectAuthService } from '../../services/direct-auth.service'; +import { MessageService } from 'primeng/api'; + +// PrimeNG Components import { ButtonModule } from 'primeng/button'; -import { PasswordModule } from 'primeng/password'; -import { DividerModule } from 'primeng/divider'; -import { MessagesModule } from 'primeng/messages'; +import { InputTextModule } from 'primeng/inputtext'; import { MessageModule } from 'primeng/message'; import { ToastModule } from 'primeng/toast'; -import { MessageService } from 'primeng/api'; -import { FooterComponent } from "../../components/footer/footer.component"; -import { AuthService } from '../../services/auth.service'; -import { take } from 'rxjs/operators'; -import { firstValueFrom } from 'rxjs'; +import { FooterComponent } from '../../components/footer/footer.component'; @Component({ selector: 'app-login', standalone: true, imports: [ CommonModule, - CardModule, - InputTextModule, + FormsModule, ButtonModule, - PasswordModule, - DividerModule, - MessagesModule, + InputTextModule, MessageModule, ToastModule, FooterComponent ], - providers: [MessageService], templateUrl: './login.component.html', - styleUrl: './login.component.scss' + styleUrls: ['./login.component.scss'] }) export class LoginComponent implements OnInit { - // Inyectar el servicio de autenticación - private authService = inject(AuthService); - private route = inject(ActivatedRoute); - private router = inject(Router); + // Variables de modelo + email: string = ''; + password: string = ''; - loading: boolean = false; - returnUrl: string = ''; + // Estados con signals + loading = signal(false); + errorMessage = signal(null); + showRecovery = signal(false); + isInitialLoad = signal(true); - constructor(private messageService: MessageService) {} + // Credenciales para recuperación + recoveryEmail: string = ''; - ngOnInit() { - // Obtener el returnUrl de los query params si existe + // URL para redirección después del login + private returnUrl: string = '/inicio'; + + constructor( + private authService: DirectAuthService, + private router: Router, + private route: ActivatedRoute, + private messageService: MessageService + ) { } + + ngOnInit(): void { + // Obtener URL de retorno de los parámetros de query this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/inicio'; - // Simplificación - verificar autenticación sin promesas anidadas - this.authService.isLoggedIn().subscribe({ - next: (isLoggedIn) => { - if (isLoggedIn) { - console.log('Usuario ya autenticado, redirigiendo a:', this.returnUrl); - // Verificar si la URL de retorno es válida - const effectiveReturnUrl = this.returnUrl === '/' ? '/inicio' : this.returnUrl; - // Redirigir - this.router.navigate([effectiveReturnUrl], { replaceUrl: true }); - } - }, - error: (error) => console.error('Error al verificar autenticación:', error) - }); + // Comprobar si ya hay sesión activa + if (this.authService.isAuthenticated()) { + this.router.navigate([this.returnUrl]); + return; + } + + // Marcar que ya no es carga inicial (para animaciones) + setTimeout(() => { + this.isInitialLoad.set(false); + }, 100); } - // Método de login con Keycloak usando nuestro servicio AuthService - async onLogin() { - this.loading = true; + /** + * Maneja el envío del formulario de login + */ + onLogin(): void { + // Validaciones básicas + if (!this.email || !this.password) { + this.errorMessage.set('Por favor ingresa email y contraseña'); + return; + } - try { - // Verificar si la URL de retorno es válida - const effectiveReturnUrl = this.returnUrl === '/' ? '/inicio' : this.returnUrl; - - // Construir el redirectUri - const redirectUri = window.location.origin + effectiveReturnUrl; - console.log('Iniciando login con redirectUri:', redirectUri); - - // Iniciar el flujo de autenticación de Keycloak - await this.authService.login(redirectUri); - // Keycloak se encargará de la redirección - } catch (error) { - console.error('Error al iniciar sesión:', error); - this.loading = false; - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Error al intentar iniciar sesión. Por favor, inténtelo de nuevo.' + this.loading.set(true); + this.errorMessage.set(null); + + this.authService.login(this.email, this.password) + .subscribe({ + next: () => { + // Mostrar mensaje de éxito + this.messageService.add({ + severity: 'success', + summary: 'Bienvenido', + detail: 'Inicio de sesión exitoso', + life: 3000 + }); + + // Redirigir al usuario + setTimeout(() => { + this.router.navigate([this.returnUrl]); + }, 300); // Add small delay to ensure token is properly stored + }, + error: (error) => { + console.error('Error de autenticación:', error); + this.loading.set(false); + + // Mostrar mensaje de error + this.errorMessage.set('Credenciales incorrectas. Por favor, verifica tu email y contraseña.'); + + // También mostrar en toast para mejor visibilidad + this.messageService.add({ + severity: 'error', + summary: 'Error de autenticación', + detail: 'Credenciales incorrectas. Por favor, verifica tu email y contraseña.', + life: 5000 + }); + } }); + } + + /** + * Alterna entre el panel de login y el de recuperación de contraseña + */ + toggleRecovery(event: Event): void { + event.preventDefault(); + this.showRecovery.update(value => !value); + this.errorMessage.set(null); // Limpiar mensajes de error al cambiar de panel + } + + /** + * Maneja la solicitud de recuperación de contraseña + */ + onRequestPasswordRecovery(): void { + if (!this.recoveryEmail) { + this.errorMessage.set('Por favor ingresa tu email'); + return; + } + + this.loading.set(true); + + // Simulación de solicitud de recuperación (reemplazar con llamada real al API) + setTimeout(() => { + this.loading.set(false); + this.messageService.add({ + severity: 'info', + summary: 'Solicitud enviada', + detail: 'Si el email existe en nuestro sistema, recibirás instrucciones para recuperar tu contraseña.', + life: 5000 + }); + + // Volver al panel de login + this.showRecovery.set(false); + this.recoveryEmail = ''; + }, 1500); + } + + /** + * Limpia errores cuando el usuario comienza a escribir + */ + clearErrors(): void { + if (this.errorMessage()) { + this.errorMessage.set(null); } } - - // Mantenemos los métodos de recuperación de contraseña para compatibilidad - toggleRecovery(event: Event) { - event.preventDefault(); - this.messageService.add({ - severity: 'info', - summary: 'Información', - detail: 'Para recuperar tu contraseña, utiliza la opción en la pantalla de login de Keycloak' - }); - } - - onRecoverPassword() { - this.messageService.add({ - severity: 'info', - summary: 'Información', - detail: 'Esta funcionalidad ahora es manejada por Keycloak' - }); - } } \ No newline at end of file diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index f600135..36dc3d8 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -1,21 +1,17 @@ -import { Injectable, inject, effect, signal, computed } from '@angular/core'; +// This file is kept for reference only and is not used in the application. +// The application now uses DirectAuthService instead. + +import { Injectable, signal } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, from } from 'rxjs'; -import { KEYCLOAK_EVENT_SIGNAL, KeycloakEventType } from 'keycloak-angular'; import { Router } from '@angular/router'; -import Keycloak from 'keycloak-js'; import { MessageService } from 'primeng/api'; +import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class AuthService { - // Inject Keycloak instance directly - private keycloak = inject(Keycloak); - private keycloakEvents = inject(KEYCLOAK_EVENT_SIGNAL); - private router = inject(Router); - private messageService = inject(MessageService); - // User state private userSubject = new BehaviorSubject(null); public user$ = this.userSubject.asObservable(); @@ -23,218 +19,51 @@ export class AuthService { // Authentication state as a signal public isAuthenticated = signal(false); - // Login error state - public loginError = signal(null); - - constructor(private http: HttpClient) { - // Check initial state - this.checkInitialAuthState(); - - // Set up event handlers using Angular effects - effect(() => { - const event = this.keycloakEvents(); - if (!event) return; - - console.log('Keycloak event:', event.type); - - // On successful login - if (event.type === KeycloakEventType.AuthSuccess) { - this.isAuthenticated.set(true); - this.loginError.set(null); - this.loadUserInfo(); - } - - // On logout - if (event.type === KeycloakEventType.AuthLogout) { - this.isAuthenticated.set(false); - this.userSubject.next(null); - this.router.navigate(['/login']); - } - - // On authentication error - if (event.type === KeycloakEventType.AuthError) { - console.error('Authentication error:', event); - this.isAuthenticated.set(false); - this.userSubject.next(null); - - // Mostrar mensaje de error - const errorMsg = 'Error de autenticación. Por favor, verifica tus credenciales o inténtalo más tarde.'; - this.loginError.set(errorMsg); - this.messageService.add({ - severity: 'error', - summary: 'Error de autenticación', - detail: errorMsg, - life: 5000 - }); - } - - // On token expiration - if (event.type === KeycloakEventType.TokenExpired) { - console.log('Token expired, refreshing...'); - this.updateToken(); - } - }); - } - - private async checkInitialAuthState(): Promise { - try { - const isLoggedIn = await this.keycloak.authenticated; - this.isAuthenticated.set(isLoggedIn); - - if (isLoggedIn) { - await this.loadUserInfo(); - } - } catch (error) { - console.error('Error checking initial auth state:', error); - this.isAuthenticated.set(false); - - // Mostrar mensaje de error - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'No se pudo verificar el estado de autenticación.', - life: 5000 - }); - } - } - - private async loadUserInfo(): Promise { - try { - const isLoggedIn = await this.keycloak.authenticated; - - if (!isLoggedIn) { - this.userSubject.next(null); - return; - } - - const userProfile = await this.keycloak.loadUserProfile(); - const isAdmin = this.keycloak.hasRealmRole('admin'); - - // Get user roles - const realmRoles = this.keycloak.realmAccess?.roles || []; - const resourceRoles = this.keycloak.resourceAccess || {}; - - const user = { - id: userProfile.id, - username: userProfile.username, - name: `${userProfile.firstName || ''} ${userProfile.lastName || ''}`.trim(), - email: userProfile.email, - role: isAdmin ? 'admin' : 'user', - roles: { - realm: realmRoles, - resource: resourceRoles - }, - isAdmin: isAdmin - }; - - this.userSubject.next(user); - } catch (error) { - console.error('Error loading user profile:', error); - this.userSubject.next(null); - - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'No se pudo cargar la información del usuario.', - life: 5000 - }); - } - } - + constructor( + private http: HttpClient, + private router: Router, + private messageService: MessageService + ) {} + async login(redirectUri?: string): Promise { - try { - this.loginError.set(null); - await this.keycloak.login({ - redirectUri: redirectUri || window.location.origin - }); - } catch (error) { - console.error('Login error:', error); - this.loginError.set('Error al iniciar sesión. Por favor, inténtalo de nuevo.'); - - this.messageService.add({ - severity: 'error', - summary: 'Error de inicio de sesión', - detail: 'No se pudo iniciar sesión. Por favor, inténtalo de nuevo.', - life: 5000 - }); - - throw error; - } + console.warn('AuthService is deprecated. Use DirectAuthService instead.'); + return Promise.reject('AuthService is deprecated. Use DirectAuthService instead.'); } async logout(): Promise { - try { - await this.keycloak.logout({ - redirectUri: window.location.origin - }); - } catch (error) { - console.error('Logout error:', error); - // Intento manual de navegar a login en caso de error - this.router.navigate(['/login']); - - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Error al cerrar sesión.', - life: 5000 - }); - } + console.warn('AuthService is deprecated. Use DirectAuthService instead.'); + return Promise.resolve(); } isLoggedIn(): Observable { - try { - // Usar directamente la propiedad authenticated de Keycloak - return from(Promise.resolve(this.keycloak.authenticated || false)); - } catch (error) { - console.error('Error al verificar autenticación:', error); - return from(Promise.resolve(false)); - } + console.warn('AuthService is deprecated. Use DirectAuthService instead.'); + return from(Promise.resolve(false)); } getToken(): Promise { - try { - return Promise.resolve(this.keycloak.token || ''); - } catch (error) { - console.error('Error al obtener token:', error); - return Promise.resolve(''); - } + console.warn('AuthService is deprecated. Use DirectAuthService instead.'); + return Promise.resolve(''); } - async updateToken(minValidity = 30): Promise { - try { - return await this.keycloak.updateToken(minValidity); - } catch (error) { - console.error('Error refreshing token:', error); - // No redireccionar automáticamente al login, mostrar mensaje primero - this.messageService.add({ - severity: 'warn', - summary: 'Sesión expirada', - detail: 'Tu sesión ha expirado. Por favor, inicia sesión nuevamente.', - life: 5000 - }); - - // Esperar un momento para que el usuario vea el mensaje - setTimeout(() => this.login(), 2000); - return false; - } + async updateToken(): Promise { + console.warn('AuthService is deprecated. Use DirectAuthService instead.'); + return false; } getCurrentUser(): any { - return this.userSubject.value; + console.warn('AuthService is deprecated. Use DirectAuthService instead.'); + return null; } // Check if user has a specific role hasRole(role: string): boolean { - return this.keycloak.hasRealmRole(role); + console.warn('AuthService is deprecated. Use DirectAuthService instead.'); + return false; } // Check if user has any of the specified roles hasAnyRole(roles: string[]): boolean { - for (const role of roles) { - if (this.keycloak.hasRealmRole(role)) { - return true; - } - } + console.warn('AuthService is deprecated. Use DirectAuthService instead.'); return false; } } \ No newline at end of file diff --git a/src/app/services/direct-auth.service.ts b/src/app/services/direct-auth.service.ts new file mode 100644 index 0000000..0e6b051 --- /dev/null +++ b/src/app/services/direct-auth.service.ts @@ -0,0 +1,354 @@ +import { Injectable, signal, inject } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { Router } from '@angular/router'; +import { MessageService } from 'primeng/api'; +import { environment } from '../../environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class DirectAuthService { + // URLs del servidor Keycloak from environment + private keycloakUrl = environment.keycloak.url; + private realm = environment.keycloak.realm; + private clientId = environment.keycloak.clientId; + private tokenEndpoint = `${this.keycloakUrl}/realms/${this.realm}/protocol/openid-connect/token`; + + // Router y MessageService + private router = inject(Router); + private messageService = inject(MessageService); + + // Estado de autenticación como signal + private userInfo = signal(null); + + // Token y refresh token + private tokenInfo: any = null; + + // Temporizador para renovación de token + private refreshTokenTimeout: any; + + // Idle detection + private userActivity: any = null; + private userInactive = signal(false); + + // Percentage for inactivity timeout (90% of token lifetime) + private readonly INACTIVITY_PERCENTAGE = 0.9; + + constructor(private http: HttpClient) { + // Intentar cargar el token del almacenamiento local al iniciar + this.loadTokenFromStorage(); + + // Iniciar monitoreo de actividad + this.setupActivityMonitoring(); + } + + // Cargar token del almacenamiento local + private loadTokenFromStorage(): void { + const tokenInfo = localStorage.getItem('keycloak_token'); + if (tokenInfo) { + try { + this.tokenInfo = JSON.parse(tokenInfo); + + // Verificar si el token ha expirado + if (this.isTokenExpired()) { + // Si tiene refresh token, intentar renovar + if (this.tokenInfo.refresh_token) { + this.refreshToken().subscribe(); + } else { + this.logout(); + } + } else { + // Decodificar info del usuario desde el token + this.setUserFromToken(this.tokenInfo.access_token); + // Configurar temporizador para renovación de token + this.startRefreshTokenTimer(); + } + } catch (e) { + console.error('Error al cargar token:', e); + this.logout(); + } + } + } + + // Login directo con credenciales + public login(username: string, password: string): Observable { + const headers = new HttpHeaders({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + + const params = new HttpParams() + .set('client_id', this.clientId) + .set('grant_type', 'password') + .set('username', username) + .set('password', password); + + return this.http.post(this.tokenEndpoint, params.toString(), { headers }) + .pipe( + tap(tokenInfo => { + // Guardar información del token + this.tokenInfo = tokenInfo; + localStorage.setItem('keycloak_token', JSON.stringify(tokenInfo)); + + // Decodificar info del usuario + this.setUserFromToken(tokenInfo.access_token); + + // Configurar temporizador para renovación de token + this.startRefreshTokenTimer(); + + // Reiniciar detección de inactividad + this.resetInactivity(); + }), + catchError(error => { + console.error('Error de autenticación:', error); + return throwError(() => new Error('Credenciales incorrectas o error de servidor')); + }) + ); + } + + // Cerrar sesión + public logout(): void { + // Detener temporizador de renovación + this.stopRefreshTokenTimer(); + + // Detener monitoreo de actividad + this.stopActivityMonitoring(); + + // Limpiar datos de sesión + localStorage.removeItem('keycloak_token'); + this.tokenInfo = null; + this.userInfo.set(null); + + // Redirigir a la página de login + this.router.navigate(['/login']); + } + + // Renovar token usando refresh token + public refreshToken(): Observable { + if (!this.tokenInfo?.refresh_token) { + return throwError(() => new Error('No hay refresh token disponible')); + } + + // No refrescar token si el usuario está inactivo + if (this.userInactive()) { + console.log('Usuario inactivo, no se renovará el token'); + this.logout(); + return throwError(() => new Error('Usuario inactivo')); + } + + const headers = new HttpHeaders({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + + const params = new HttpParams() + .set('client_id', this.clientId) + .set('grant_type', 'refresh_token') + .set('refresh_token', this.tokenInfo.refresh_token); + + return this.http.post(this.tokenEndpoint, params.toString(), { headers }) + .pipe( + tap(newTokenInfo => { + // Actualizar información del token + this.tokenInfo = newTokenInfo; + localStorage.setItem('keycloak_token', JSON.stringify(newTokenInfo)); + + // Actualizar información del usuario si es necesario + this.setUserFromToken(newTokenInfo.access_token); + + // Reiniciar temporizador para renovación de token + this.startRefreshTokenTimer(); + + // Reiniciar detección de inactividad + this.resetInactivity(); + }), + catchError(error => { + console.error('Error al renovar token:', error); + // Si falla la renovación, forzar cierre de sesión + this.logout(); + return throwError(() => new Error('Error al renovar la sesión')); + }) + ); + } + + // Obtener token actual + public getToken(): string { + return this.tokenInfo?.access_token || ''; + } + + // Verificar si hay usuario autenticado + public isAuthenticated(): boolean { + return !!this.tokenInfo?.access_token && !this.isTokenExpired(); + } + + // Obtener usuario actual + public getCurrentUser(): any { + return this.userInfo(); + } + + // Verificar si el token ha expirado + private isTokenExpired(): boolean { + if (!this.tokenInfo?.access_token) { + return true; + } + + try { + const tokenParts = this.tokenInfo.access_token.split('.'); + if (tokenParts.length !== 3) { + return true; + } + + const payload = JSON.parse(atob(tokenParts[1])); + const expirationTime = payload.exp * 1000; // Convertir a milisegundos + const currentTime = new Date().getTime(); + + return currentTime >= expirationTime; + } catch (e) { + console.error('Error al verificar expiración del token:', e); + return true; + } + } + + // Decodificar token y extraer información del usuario + private setUserFromToken(token: string): void { + try { + const tokenParts = token.split('.'); + if (tokenParts.length !== 3) { + throw new Error('Formato de token inválido'); + } + + const payload = JSON.parse(atob(tokenParts[1])); + + // Extraer información del usuario del payload + const user = { + id: payload.sub, + username: payload.preferred_username, + name: payload.name, + email: payload.email, + roles: payload.realm_access?.roles || [], + isAdmin: (payload.realm_access?.roles || []).includes('admin') + }; + + this.userInfo.set(user); + } catch (e) { + console.error('Error al decodificar token:', e); + this.userInfo.set(null); + } + } + + // Configurar temporizador para renovación automática del token + private startRefreshTokenTimer(): void { + // Detener cualquier temporizador existente + this.stopRefreshTokenTimer(); + + // Calcular tiempo de expiración + try { + if (!this.tokenInfo?.access_token) { + return; + } + + const tokenParts = this.tokenInfo.access_token.split('.'); + const payload = JSON.parse(atob(tokenParts[1])); + const expirationTime = payload.exp * 1000; // Convertir a milisegundos + const currentTime = new Date().getTime(); + + // Renovar cuando quede el 70% del tiempo de validez + const timeToExpiry = expirationTime - currentTime; + const refreshTime = timeToExpiry * 0.7; + + this.refreshTokenTimeout = setTimeout(() => { + this.refreshToken().subscribe(); + }, refreshTime); + } catch (e) { + console.error('Error al configurar renovación de token:', e); + } + } + + // Detener temporizador de renovación de token + private stopRefreshTokenTimer(): void { + if (this.refreshTokenTimeout) { + clearTimeout(this.refreshTokenTimeout); + this.refreshTokenTimeout = null; + } + } + + // Obtener tiempo de expiración del token en milisegundos + private getTokenExpirationTime(): number { + if (!this.tokenInfo?.access_token) { + return 0; + } + + try { + // Obtener información del token + const tokenParts = this.tokenInfo.access_token.split('.'); + if (tokenParts.length !== 3) { + return 0; + } + + // Decodificar la parte de payload del token + const payload = JSON.parse(atob(tokenParts[1])); + + // Obtener el tiempo de expiración + if (!payload.exp) { + return 0; + } + + // Calcular el tiempo restante en milisegundos + const expirationTime = payload.exp * 1000; // Convertir de segundos a milisegundos + const currentTime = new Date().getTime(); + const timeRemaining = expirationTime - currentTime; + + return Math.max(0, timeRemaining); + } catch (error) { + console.error('Error al obtener tiempo de expiración del token:', error); + return 0; + } + } + + // Configurar monitoreo de actividad del usuario + private setupActivityMonitoring(): void { + if (typeof window !== 'undefined') { + // Lista de eventos para detectar actividad del usuario + const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; + + // Agregar listener para cada evento + events.forEach(eventName => { + window.addEventListener(eventName, () => this.resetInactivity(), true); + }); + + // Iniciar timer de inactividad + this.resetInactivity(); + } + } + + // Detener monitoreo de actividad + private stopActivityMonitoring(): void { + if (this.userActivity) { + clearTimeout(this.userActivity); + this.userActivity = null; + } + + this.userInactive.set(false); + } + + // Reiniciar timer de inactividad basado en un porcentaje de la expiración del token + private resetInactivity(): void { + this.userInactive.set(false); + + clearTimeout(this.userActivity); + + // Obtener el tiempo de expiración del token + const tokenExpirationTime = this.getTokenExpirationTime(); + + if (tokenExpirationTime <= 0) { + return; // No configurar timer si el token ya expiró o no está disponible + } + + // Calcular el tiempo de inactividad como el porcentaje configurado del tiempo de expiración + const inactivityTime = tokenExpirationTime * this.INACTIVITY_PERCENTAGE; + + this.userActivity = setTimeout(() => { + this.userInactive.set(true); + }, inactivityTime); + } +} \ No newline at end of file diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts new file mode 100644 index 0000000..7947f62 --- /dev/null +++ b/src/environments/environment.prod.ts @@ -0,0 +1,14 @@ +export const environment = { + production: true, + keycloak: { + url: 'http://192.168.1.27:8080', + realm: 'angular-app', + clientId: 'angular-app', + credentials: { + secret: 'zYbODELDmLjK9c9gHNbTUe8mSZlcLFZm' + } + }, + api: { + baseUrl: '/api' + } +}; \ No newline at end of file diff --git a/src/environments/environment.ts b/src/environments/environment.ts new file mode 100644 index 0000000..510c94f --- /dev/null +++ b/src/environments/environment.ts @@ -0,0 +1,14 @@ +export const environment = { + production: false, + keycloak: { + url: 'http://192.168.1.27:8080', + realm: 'angular-app', + clientId: 'angular-app', + credentials: { + secret: 'zYbODELDmLjK9c9gHNbTUe8mSZlcLFZm' + } + }, + api: { + baseUrl: '/api' + } +}; \ No newline at end of file