Compare commits

...

14 Commits
dev ... master

Author SHA1 Message Date
89e97a3634 guardias 2025-05-22 09:52:40 -04:00
04b7840341 Updates tutorial for clarity and consistency
Refines the tutorial document by standardizing list formatting,
correcting minor typographical errors, and updating outdated
references, thereby enhancing readability and ensuring technical
accuracy for users setting up the authentication system.
2025-05-20 11:56:11 -04:00
6b351ff5b3 Control de acceso basado en funciones
Añade a la aplicación el control de acceso basado en roles mediante guardias.
Introduce guardias de acceso, administración, grupo y rol para la protección de rutas.
Actualiza la barra lateral para incluir elementos de menú basados en roles.
Configura el cliente Keycloak para incluir información de grupo en el token de acceso, habilitando la autorización basada en grupos.
Añade la página de acceso denegado.
2025-05-20 10:42:24 -04:00
55fc1f6278 Removes login hints and adds VM link
Removes the test credentials and password recovery link from the login form.

Adds a link to download a pre-configured virtual machine for Keycloak.
2025-05-19 18:21:17 -04:00
56eee3fb97 Updates Keycloak admin console access info
Updates the Keycloak admin console access URL to reflect the server's IP address.

Clarifies that login credentials depend on previously created users and the server's IP.
2025-05-19 18:03:45 -04:00
68b466bd3c access grant 2025-05-19 17:52:00 -04:00
f2ce7327d8 Mejora de la autenticación y la gestión de tokens
Mejora la lógica de actualización de tokens teniendo en cuenta la actividad del usuario y las configuraciones del entorno.
Elimina el obsoleto AuthService e integra el registro de actividad para una mejor supervisión.
Actualiza las configuraciones del entorno para gestionar los tiempos de inactividad y el comportamiento de renovación de tokens.

Aborda los posibles problemas de renovación de tokens relacionados con los usuarios inactivos y mejora la seguridad aplicando políticas estrictas de renovación de tokens.

Traducción realizada con la versión gratuita del traductor DeepL.com
2025-05-19 17:38:05 -04:00
8a1434e553 keycloack direct funcionando
se implemente el direct access grant para probar el post login
2025-05-19 14:18:31 -04:00
1dd5f1644f update 2025-05-15 17:07:31 -04:00
db0815f3ed Soluciona el problema de sincronización de grupos LDAP en el tutorial de Keycloak
Soluciona un problema por el que los miembros del grupo no se mostraban en Keycloak
después de la sincronización LDAP.

Explica la importancia de configurar «Membership Attribute Type» a «UID»
en lugar de «DN» en la configuración del mapeador de grupos LDAP. Esto asegura
correcta recuperación de miembros basada en el simple ID de usuario almacenado en la estructura LDAP.
de LDAP.

Explains the importance of setting "Membership Attribute Type" to "UID"
instead of "DN" in the LDAP group mapper configuration. This ensures
correct member retrieval based on the simple user ID stored in the LDAP
structure.
2025-05-13 18:21:33 -04:00
1ca16b0e94 keyclock 2025-05-13 17:06:16 -04:00
c84c9a95c8 keycloack funcionando 2025-05-13 15:58:00 -04:00
01b93eca37 funcionando v1 2025-05-13 15:21:24 -04:00
ea91f1c8f0 Merge branch 'dev' 2025-05-09 12:42:24 -04:00
41 changed files with 4012 additions and 1412 deletions

11
.env.template Normal file
View File

@ -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

387
README.md
View File

@ -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 ## Instalación
### Requisitos Previos ### Requisitos Previos
- Node.js (versión 20.x recomendada) - Node.js (versión 20.x o superior)
- npm (incluido con Node.js) - npm (incluido con Node.js)
- Git - Keycloak Server (para autenticación)
### Pasos de Instalación ### Pasos de Instalación
@ -16,18 +24,40 @@ Este proyecto es una aplicación Angular 19 utilizando PrimeNG para la gestión
```bash ```bash
git clone <URL-del-repositorio> git clone <URL-del-repositorio>
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 ```bash
npm install 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. 4. **Iniciar el Servidor de Desarrollo**
3. **Iniciar el Servidor de Desarrollo**
```bash ```bash
npm start 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/` 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 1. **Instalar y Ejecutar Keycloak**:
```bash - Descargue Keycloak desde [keycloak.org](https://www.keycloak.org/downloads)
npm install @angular/core @angular/common @angular/forms @angular/router @angular/compiler - 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 2. **Crear un Realm**:
```bash - Cree un nuevo realm llamado `angular-app`
npm install primeng @primeng/themes - Este nombre debe coincidir con su variable `KEYCLOAK_REALM` en `.env`
```
- **PrimeFlex 4.0.0**: Sistema de CSS flexible 3. **Crear un Cliente**:
```bash - Cree un nuevo cliente con ID `angular-app` (debe coincidir con `KEYCLOAK_CLIENT_ID`)
npm install primeflex - 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 4. **Configurar Usuarios y Roles**:
```bash - Cree usuarios para pruebas
npm install primeicons - Configure roles como `admin`, `user`, etc.
``` - Asigne roles a los usuarios
- **PDFMake 0.2.19**: Para generación de PDFs ### Integración con la Aplicación
```bash
npm install pdfmake @types/pdfmake
```
- **ExcelJS 4.4.0**: Para exportación a Excel 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:
```bash
npm install exceljs
```
- **File-Saver 2.5.0**: Para guardar archivos en el cliente - Utiliza el protocolo OpenID Connect para autenticación directa
```bash - Gestiona tokens manualmente para mayor control
npm install file-saver @types/file-saver - Implementa renovación automática de tokens
``` - Provee detección de inactividad de usuario
- **Animate.css 4.1.1**: Para animaciones CSS ## Entornos de Ejecución
```bash
npm install animate.css
```
## 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 ## Flujo de Autenticación
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
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 4. **Interceptor HTTP**:
"styles": [ - Todas las solicitudes HTTP incluyen automáticamente el token de autenticación
"src/styles.scss", - En caso de error 401, se intenta renovar el token automáticamente
{
"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
## Comandos Disponibles ## Comandos Disponibles
| Comando | Descripción | | Comando | Descripción |
|---------|-------------| |---------|-------------|
| `npm start` | Inicia servidor de desarrollo | | `npm start` | Inicia servidor de desarrollo en http://localhost:4200 |
| `npm run build` | Compila el proyecto | | `npm run build` | Compila el proyecto para desarrollo |
| `npm run build:prod` | Compila para producción | | `npm run build:prod` | Compila para producción con optimizaciones |
| `npm run watch` | Compila en modo observador |
| `npm test` | Ejecuta pruebas unitarias | | `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 ## Solución de Problemas
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
## Buenas Prácticas ### Problemas Comunes de Autenticación
1. **Organización de Componentes**: 1. **No se puede iniciar sesión**:
- Componentes de página en la carpeta `pages/` - Verifique que las credenciales sean correctas
- Componentes reutilizables en `components/` - 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**: 2. **Token expirado o inválido**:
- Definir interfaces para todos los modelos en `models/` - La aplicación debería renovar automáticamente el token
- Exportar todas las interfaces desde `models/index.ts` - Si persiste, intente cerrar sesión y volver a iniciar sesión
3. **Servicios**: 3. **Redirecciones en bucle**:
- Mantener la responsabilidad única para cada servicio - Limpie el localStorage del navegador
- Centralizar la lógica de negocio en los servicios - Verifique la configuración de URLs en el cliente Keycloak
4. **Gestión de Estado**:
- Utilizar servicios para compartir estado entre componentes
## Recursos Adicionales ## Recursos Adicionales
- [Documentación de Angular](https://angular.dev/) - [Documentación oficial de Keycloak](https://www.keycloak.org/documentation)
- [Documentación de PrimeNG](https://primeng.org/installation) - [Angular Security Best Practices](https://angular.io/guide/security)
- [Documentación de PrimeFlex](https://primeflex.org/) - [Tutorial completo de Keycloak](tutorial-keycloak-completo.md)
- [Documentación de PDFMake](http://pdfmake.org/)
- [Documentación de ExcelJS](https://github.com/exceljs/exceljs)

View File

@ -3,7 +3,7 @@
"version": 1, "version": 1,
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"cronogramas": { "correo": {
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
@ -17,7 +17,7 @@
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"outputPath": "dist/cronogramas", "outputPath": "dist/correo",
"index": "src/index.html", "index": "src/index.html",
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": [ "polyfills": [
@ -82,10 +82,10 @@
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "cronogramas:build:production" "buildTarget": "correo:build:production"
}, },
"development": { "development": {
"buildTarget": "cronogramas:build:development" "buildTarget": "correo:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"

1658
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{ {
"name": "cronogramas", "name": "correo",
"version": "0.1.0", "version": "0.1.0",
"engines": { "engines": {
"node": ">=18.x" "node": ">=18.x"
@ -27,6 +27,8 @@
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"keycloak-angular": "^19.0.2",
"keycloak-js": "^26.2.0",
"pdfmake": "^0.2.19", "pdfmake": "^0.2.19",
"primeflex": "^4.0.0", "primeflex": "^4.0.0",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@ -51,5 +53,5 @@
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2" "typescript": "~5.7.2"
}, },
"description": "cronogramas - Proyecto generado desde template" "description": "correo - Proyecto generado desde template"
} }

8
public/keycloak.json Normal file
View File

@ -0,0 +1,8 @@
{
"realm": "angular-app",
"auth-server-url": "http://192.168.1.27:8080/",
"resource": "angular-app",
"credentials": {
"secret": "zYbODELDmLjK9c9gHNbTUe8mSZlcLFZm"
}
}

View File

@ -0,0 +1,7 @@
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

View File

@ -1,15 +1,73 @@
import { Component } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { ConfirmationService } from 'primeng/api'; import { ConfirmationService, MessageService } from 'primeng/api';
import { filter, Subscription } from 'rxjs';
import { ToastModule } from 'primeng/toast';
import { DirectAuthService } from './services/direct-auth.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, ConfirmDialogModule], imports: [RouterOutlet, ConfirmDialogModule, ToastModule],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
providers: [ConfirmationService] providers: [ConfirmationService, MessageService],
standalone: true,
}) })
export class AppComponent { export class AppComponent implements OnInit {
title = 'SACG - Sistema Administrador de Cronogramas'; title = 'SACG - Sistema Administrador de Cronogramas';
private authService = inject(DirectAuthService);
private router = inject(Router);
private subscriptions: Subscription[] = [];
constructor() {
// Ya no es necesario el efecto keycloak
}
ngOnInit() {
// Subscribe to navigation events - using standard unsubscribe pattern
this.subscriptions.push(
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd)
)
.subscribe(() => {
console.log('Navigation completed to:', this.router.url);
})
);
// Check authentication status on load
this.checkAuthenticationStatus();
}
ngOnDestroy(): void {
// Unsubscribe from any remaining subscriptions
this.subscriptions.forEach(sub => sub.unsubscribe());
}
private async checkAuthenticationStatus(): Promise<void> {
try {
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 login
if (!isLoggedIn && this.router.url !== '/login') {
// We'll let the auth guard handle this
console.log('Not authenticated on protected route');
}
} catch (error) {
console.error('Error checking authentication status:', error);
}
}
} }

View File

@ -1,13 +1,13 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router'; import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideAnimations } from '@angular/platform-browser/animations'; import { provideAnimations } from '@angular/platform-browser/animations';
import { providePrimeNG } from 'primeng/config'; import { providePrimeNG } from 'primeng/config';
import Aura from '@primeng/themes/aura'; import Aura from '@primeng/themes/aura';
import { authInterceptor } from './interceptors/auth.interceptor';
import { MessageService } from 'primeng/api'; import { MessageService } from 'primeng/api';
import { authInterceptor } from './interceptors/auth.interceptor';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@ -17,7 +17,13 @@ export const appConfig: ApplicationConfig = {
withPreloading(PreloadAllModules) withPreloading(PreloadAllModules)
), ),
provideAnimations(), provideAnimations(),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
// Usamos nuestro interceptor personalizado para DirectAuthService
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor])
),
providePrimeNG({ providePrimeNG({
theme: { theme: {
preset: Aura, preset: Aura,
@ -26,6 +32,7 @@ export const appConfig: ApplicationConfig = {
} }
} }
}), }),
MessageService MessageService
] ]
}; };

View File

@ -1,11 +1,18 @@
import { Routes, PreloadAllModules } from '@angular/router'; import { Routes } from '@angular/router';
import { LoginComponent } from './pages/login/login.component'; import { LoginComponent } from './pages/login/login.component';
import { LayoutComponent } from './components/layout/layout.component'; import { LayoutComponent } from './components/layout/layout.component';
import { authGuard } from './guards/auth.guard'; import { authGuard } from './guards/auth.guard';
import { groupGuard } from './guards/group.guard';
import { NotFoundComponent } from './pages/not-found/not-found.component'; import { NotFoundComponent } from './pages/not-found/not-found.component';
import { AccessDeniedComponent } from './pages/access-denied/access-denied.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'login', component: LoginComponent }, {
path: 'login',
component: LoginComponent,
data: { title: 'Login' }
},
{ {
path: '', path: '',
component: LayoutComponent, component: LayoutComponent,
@ -20,35 +27,68 @@ export const routes: Routes = [
{ {
path: 'unidad-concesiones', path: 'unidad-concesiones',
loadComponent: () => import('./pages/concesiones/concesiones.component').then(m => m.ConcesionesComponent), loadComponent: () => import('./pages/concesiones/concesiones.component').then(m => m.ConcesionesComponent),
data: { title: 'Unidad de Concesiones' } data: {
title: 'Unidad de Concesiones',
}
}, },
{ {
path: 'ct-actualizacion', path: 'ct-actualizacion',
loadComponent: () => import('./pages/actualizacion-pd/actualizacion-pd.component').then(m => m.ActualizacionPdComponent), loadComponent: () => import('./pages/actualizacion-pd/actualizacion-pd.component').then(m => m.ActualizacionPdComponent),
data: { title: 'Cronograma temporal por actualización de PD' } data: {
title: 'Cronograma temporal por actualización de PD',
}
}, },
{ {
path: 'ct-ajuste', path: 'ct-ajuste',
loadComponent: () => import('./pages/ajuste-pd/ajuste-pd.component').then(m => m.AjustePdComponent), loadComponent: () => import('./pages/ajuste-pd/ajuste-pd.component').then(m => m.AjustePdComponent),
data: { title: 'Cronograma temporal por ajuste de PD' } data: {
title: 'Cronograma temporal por ajuste de PD',
}
}, },
{ {
path: 'resumen', path: 'resumen',
loadComponent: () => import('./pages/resumen/resumen.component').then(m => m.ResumenComponent), loadComponent: () => import('./pages/resumen/resumen.component').then(m => m.ResumenComponent),
data: { title: 'Resumen' } data: {
title: 'Resumen',
}
},
// Rutas específicas por grupos
{
path: 'grupos/admin',
loadComponent: () => import('./pages/admin-area/admin-area.component').then(m => m.AdminAreaComponent),
canActivate: [groupGuard],
data: {
title: 'Área de Administrador',
groups: ['administradores'] // Solo administradores pueden acceder
}
},
{
path: 'grupos/user',
loadComponent: () => import('./pages/user-area/user-area.component').then(m => m.UserAreaComponent),
canActivate: [groupGuard],
data: {
title: 'Área de Usuario',
groups: ['administradores', 'usuarios'] // Administradores y usuarios pueden acceder
}
}, },
{ {
path: 'unidad-informacion', path: 'unidad-informacion',
loadComponent: () => import('./pages/unidad-informacion/unidad-informacion.component').then(m => m.UnidadInformacionComponent), loadComponent: () => import('./pages/unidad-informacion/unidad-informacion.component').then(m => m.UnidadInformacionComponent),
data: { title: 'Unidad de Información' } data: {
title: 'Unidad de Información',
}
}, },
{ {
path: '404', path: '404',
component: NotFoundComponent, component: NotFoundComponent,
data: { title: 'Error 404' } data: { title: 'Error 404' }
}, },
{
path: 'access-denied',
component: AccessDeniedComponent,
data: { title: 'Acceso Denegado' }
},
] ]
}, },
{ path: '**', redirectTo: '404' } { path: '**', redirectTo: '404' }
]; ];

View File

@ -7,7 +7,7 @@ import { filter, map, mergeMap } from 'rxjs/operators';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { ConfirmDialogModule } from 'primeng/confirmdialog';
import { ToastModule } from 'primeng/toast'; import { ToastModule } from 'primeng/toast';
import { AuthService } from '../../services/auth.service'; import { DirectAuthService } from '../../services/direct-auth.service';
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
@ -33,7 +33,7 @@ export class NavbarComponent implements OnInit {
private messageService: MessageService, private messageService: MessageService,
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private authService: AuthService private authService: DirectAuthService
) { ) {
this.router.events.pipe( this.router.events.pipe(
filter(event => event instanceof NavigationEnd), filter(event => event instanceof NavigationEnd),
@ -52,13 +52,12 @@ export class NavbarComponent implements OnInit {
ngOnInit() { ngOnInit() {
// Obtener nombre de usuario del usuario logueado // Obtener nombre de usuario del usuario logueado
this.authService.user$.subscribe(user => { const user = this.authService.getCurrentUser();
if (user) { if (user) {
this.userName = user.name || user.username || 'Usuario'; this.userName = user.name || user.username || 'Usuario';
} else { } else {
this.userName = 'Usuario'; this.userName = 'Usuario';
} }
});
} }
toggleSidebar() { toggleSidebar() {

View File

@ -7,12 +7,9 @@
<div class="version-badge">1.0</div> <div class="version-badge">1.0</div>
</div> </div>
<!-- Subtítulo del logo --> <!-- Subtítulo del logo -->
</div> </div>
<!-- Separador --> <!-- Separador -->
<div class="separator"></div> <div class="separator"></div>
<!-- Navigation Menu --> <!-- Navigation Menu -->
<div class="menu-container"> <div class="menu-container">
<ul class="sidebar-menu"> <ul class="sidebar-menu">
@ -23,7 +20,6 @@
<span class="active-indicator"></span> <span class="active-indicator"></span>
</a> </a>
</li> </li>
<li class="menu-item" routerLinkActive="active"> <li class="menu-item" routerLinkActive="active">
<a routerLink="/unidad-concesiones" class="menu-link"> <a routerLink="/unidad-concesiones" class="menu-link">
<i class="menu-icon pi pi-building"></i> <i class="menu-icon pi pi-building"></i>
@ -31,7 +27,6 @@
<span class="active-indicator"></span> <span class="active-indicator"></span>
</a> </a>
</li> </li>
<li class="menu-item" routerLinkActive="active"> <li class="menu-item" routerLinkActive="active">
<a routerLink="/ct-actualizacion" class="menu-link"> <a routerLink="/ct-actualizacion" class="menu-link">
<i class="menu-icon pi pi-flag"></i> <i class="menu-icon pi pi-flag"></i>
@ -39,7 +34,6 @@
<span class="active-indicator"></span> <span class="active-indicator"></span>
</a> </a>
</li> </li>
<li class="menu-item" routerLinkActive="active"> <li class="menu-item" routerLinkActive="active">
<a routerLink="/ct-ajuste" class="menu-link"> <a routerLink="/ct-ajuste" class="menu-link">
<i class="menu-icon pi pi-sliders-h"></i> <i class="menu-icon pi pi-sliders-h"></i>
@ -47,7 +41,6 @@
<span class="active-indicator"></span> <span class="active-indicator"></span>
</a> </a>
</li> </li>
<li class="menu-item" routerLinkActive="active"> <li class="menu-item" routerLinkActive="active">
<a routerLink="/resumen" class="menu-link"> <a routerLink="/resumen" class="menu-link">
<i class="menu-icon pi pi-list"></i> <i class="menu-icon pi pi-list"></i>
@ -55,7 +48,6 @@
<span class="active-indicator"></span> <span class="active-indicator"></span>
</a> </a>
</li> </li>
<li class="menu-item" routerLinkActive="active"> <li class="menu-item" routerLinkActive="active">
<a routerLink="/unidad-informacion" class="menu-link"> <a routerLink="/unidad-informacion" class="menu-link">
<i class="menu-icon pi pi-box"></i> <i class="menu-icon pi pi-box"></i>
@ -63,6 +55,40 @@
<span class="active-indicator"></span> <span class="active-indicator"></span>
</a> </a>
</li> </li>
<!-- Menú desplegable para Prueba de Roles -->
<li class="menu-item" [class.active]="isRolesMenuOpen">
<div class="menu-link" (click)="toggleRolesMenu()">
<i class="menu-icon pi pi-shield"></i>
<span class="menu-text">Prueba de Roles</span>
<i class="pi" [class.pi-chevron-down]="isRolesMenuOpen" [class.pi-chevron-right]="!isRolesMenuOpen"
style="margin-left: auto;"></i>
<span class="active-indicator"></span>
</div>
</li>
<!-- Submenú de Roles (visible cuando isRolesMenuOpen es true) -->
<div *ngIf="isRolesMenuOpen" class="submenu">
<li class="menu-item" routerLinkActive="active">
<a routerLink="/grupos/admin" class="menu-link" style="padding-left: 2.5rem;">
<i class="menu-icon pi pi-user-plus"></i>
<span class="menu-text">Solo Admin</span>
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/grupos/user" class="menu-link" style="padding-left: 2.5rem;">
<i class="menu-icon pi pi-user"></i>
<span class="menu-text">Solo Usuario</span>
<span class="active-indicator"></span>
</a>
</li>
<li class="menu-item" routerLinkActive="active">
<a routerLink="/roles/public" class="menu-link" style="padding-left: 2.5rem;">
<i class="menu-icon pi pi-users"></i>
<span class="menu-text">Acceso Público</span>
<span class="active-indicator"></span>
</a>
</li>
</div>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -216,3 +216,114 @@
opacity: 0.6; opacity: 0.6;
} }
} }
// Estilos adicionales para el submenu de roles
/* Estilos para el submenú */
.submenu {
margin-left: 0.5rem;
animation: slideDown 0.3s ease-in-out;
overflow: hidden;
border-left: 1px dashed rgba(163, 197, 230, 0.7);
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
@keyframes slideDown {
from {
max-height: 0;
opacity: 0;
transform: translateY(-10px);
}
to {
max-height: 200px;
opacity: 1;
transform: translateY(0);
}
}
/* Para hacer el elemento del menú principal como un botón */
.menu-item .menu-link {
cursor: pointer;
}
/* Estilo especial para el elemento del menú desplegable */
.menu-item .menu-link:has(i.pi-chevron-right),
.menu-item .menu-link:has(i.pi-chevron-down) {
position: relative;
overflow: hidden;
}
/* Efecto de onda al hacer clic en el menú desplegable */
.menu-item .menu-link:has(i.pi-chevron-right)::after,
.menu-item .menu-link:has(i.pi-chevron-down)::after {
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
background-image: radial-gradient(circle, rgba(255,255,255,0.4) 0%, transparent 10.5%);
background-repeat: no-repeat;
background-position: 50%;
transform: scale(10,10);
opacity: 0;
transition: transform .3s, opacity 0.8s;
}
.menu-item .menu-link:active:has(i.pi-chevron-right)::after,
.menu-item .menu-link:active:has(i.pi-chevron-down)::after {
transform: scale(0,0);
opacity: .3;
transition: 0s;
}
/* Estilos para elementos del submenú */
.submenu .menu-item .menu-link {
position: relative;
transition: all 0.3s ease;
}
.submenu .menu-item .menu-link::before {
content: '';
position: absolute;
top: 50%;
left: 1rem;
width: 5px;
height: 5px;
background-color: #a3c5e6;
border-radius: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.3s ease;
}
.submenu .menu-item .menu-link:hover::before {
opacity: 1;
}
.submenu .menu-item.active .menu-link::before {
opacity: 1;
background-color: white;
box-shadow: 0 0 4px rgba(255,255,255,0.7);
}
/* Estilos especiales para el submenú activo */
.menu-item.active + .submenu {
border-left: 1px solid rgba(255,255,255,0.5);
}
/* Animación para el icono de chevron */
.menu-item .pi-chevron-right,
.menu-item .pi-chevron-down {
transition: transform 0.3s ease;
}
.menu-item .pi-chevron-down {
transform: rotate(0deg);
}
.menu-item .pi-chevron-right {
transform: rotate(-90deg);
}

View File

@ -1,14 +1,25 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { CommonModule } from '@angular/common';
import { PrimeIcons } from 'primeng/api'; import { RouterModule } from '@angular/router';
import { DirectAuthService } from '../../services/direct-auth.service';
@Component({ @Component({
selector: 'app-sidebar', selector: 'app-sidebar',
imports: [RouterLink, RouterLinkActive], standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './sidebar.component.html', templateUrl: './sidebar.component.html',
styleUrl: './sidebar.component.scss', styleUrls: ['./sidebar.component.scss']
standalone: true
}) })
export class SidebarComponent { export class SidebarComponent {
isRolesMenuOpen = false;
constructor(private authService: DirectAuthService) {}
toggleRolesMenu() {
this.isRolesMenuOpen = !this.isRolesMenuOpen;
}
getCurrentUser() {
return this.authService.getCurrentUser();
}
} }

View File

@ -0,0 +1,62 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
/**
* Guard combinado que verifica si el usuario tiene los roles y/o pertenece a los grupos requeridos
* Espera un array de roles en route.data['roles'] y/o un array de grupos en route.data['groups']
* El parámetro route.data['requireAll'] determina si se requieren todos los roles/grupos o solo alguno
* Si no cumple con los requisitos, redirige a la página de acceso denegado
*/
export const accessGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Primero verificar si está autenticado
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
// Obtener los roles y grupos requeridos del data de la ruta
const requiredRoles = route.data['roles'] as Array<string> || [];
const requiredGroups = route.data['groups'] as Array<string> || [];
const requireAll = route.data['requireAll'] as boolean || false;
// Si no hay roles ni grupos requeridos, permitir acceso
if (requiredRoles.length === 0 && requiredGroups.length === 0) {
return true;
}
// Verificar acceso según la configuración
let hasAccess = false;
if (requireAll) {
// Necesita cumplir TODOS los requisitos
let hasRequiredRoles = true;
let hasRequiredGroups = true;
if (requiredRoles.length > 0) {
hasRequiredRoles = authService.hasAllRoles(requiredRoles);
}
if (requiredGroups.length > 0) {
hasRequiredGroups = authService.inAllGroups(requiredGroups);
}
hasAccess = hasRequiredRoles && hasRequiredGroups;
} else {
// Necesita cumplir ALGUNO de los requisitos
hasAccess = (requiredRoles.length > 0 && authService.hasAnyRole(requiredRoles)) ||
(requiredGroups.length > 0 && authService.inAnyGroup(requiredGroups));
}
if (hasAccess) {
return true;
}
// Si no cumple los requisitos, redirigir a página de acceso denegado
return router.createUrlTree(['/access-denied']);
};

View File

@ -0,0 +1,28 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
/**
* Guard que verifica si el usuario es administrador
* Si no es administrador, redirige a la página de acceso denegado
*/
export const adminGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Primero verificar si está autenticado
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
// Verificar si es administrador
if (authService.isAdmin()) {
return true;
}
// Si no es administrador, redirigir a página de acceso denegado
return router.createUrlTree(['/access-denied']);
};

View File

@ -1,16 +1,22 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service'; import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
export const authGuard: CanActivateFn = () => { /**
const authService = inject(AuthService); * Guard que verifica si el usuario está autenticado
* Si el usuario no está autenticado, redirige al login con la URL de retorno
*/
export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router); const router = inject(Router);
if (authService.isLoggedIn()) { if (authService.isAuthenticated()) {
return true; return true;
} }
// Redirect to login if not authenticated // Si no está autenticado, redirigir a login con la URL de retorno
router.navigate(['/login']); return router.createUrlTree(['/login'], {
return false; queryParams: { returnUrl: state.url }
});
}; };

View File

@ -0,0 +1,37 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
/**
* Guard que verifica si el usuario pertenece a los grupos requeridos
* Espera un array de grupos en route.data['groups']
* Si no pertenece a los grupos necesarios, redirige a la página de acceso denegado
*/
export const groupGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Primero verificar si está autenticado
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
// Obtener los grupos requeridos del data de la ruta
const requiredGroups = route.data['groups'] as Array<string>;
// Si no hay grupos requeridos, permitir acceso
if (!requiredGroups || requiredGroups.length === 0) {
return true;
}
// Usar la función de servicio para verificar grupos
if (authService.inAnyGroup(requiredGroups)) {
return true;
}
// Si no pertenece a los grupos necesarios, redirigir a página de acceso denegado
return router.createUrlTree(['/access-denied']);
};

9
src/app/guards/index.ts Normal file
View File

@ -0,0 +1,9 @@
// Archivo de barril (index.ts) para exportar todos los guards
export * from './auth.guard';
export * from './role.guard';
export * from './group.guard';
export * from './access.guard';
export * from './admin.guard';
// Esto permite importar todos los guards de una vez:
// import { authGuard, roleGuard, groupGuard, accessGuard, adminGuard } from './guards';

View File

@ -0,0 +1,37 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { DirectAuthService } from '../services/direct-auth.service';
/**
* Guard que verifica si el usuario tiene los roles requeridos
* Espera un array de roles en route.data['roles']
* Si no tiene los roles necesarios, redirige a la página de acceso denegado
*/
export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean | UrlTree => {
const authService = inject(DirectAuthService);
const router = inject(Router);
// Primero verificar si está autenticado
if (!authService.isAuthenticated()) {
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
}
// Obtener los roles requeridos del data de la ruta
const requiredRoles = route.data['roles'] as Array<string>;
// Si no hay roles requeridos, permitir acceso
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
// Usar la función de servicio para verificar roles
if (authService.hasAnyRole(requiredRoles)) {
return true;
}
// Si no tiene los roles necesarios, redirigir a página de acceso denegado
return router.createUrlTree(['/access-denied']);
};

View File

@ -1,46 +1,55 @@
import { HttpHandlerFn, HttpInterceptorFn, HttpRequest, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { import { catchError, switchMap, throwError } from 'rxjs';
HttpRequest, import { DirectAuthService } from '../services/direct-auth.service';
HttpHandlerFn, import { environment } from '../../environments/environment';
HttpInterceptorFn,
HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
import { Router } from '@angular/router';
export const authInterceptor: HttpInterceptorFn = ( export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
request: HttpRequest<unknown>, const authService = inject(DirectAuthService);
next: HttpHandlerFn
): Observable<any> => {
const authService = inject(AuthService);
const router = inject(Router);
// Get the auth token // No interceptar peticiones al endpoint de token (evitar bucles)
const token = authService.getToken(); if (req.url.includes('/protocol/openid-connect/token')) {
return next(req);
// Clone the request and add the token if it exists
if (token) {
const authRequest = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
} }
});
// Handle the authenticated request // Agregar token de autenticación si está disponible
return next(authRequest).pipe( const token = authService.getToken();
catchError((error: HttpErrorResponse) => { if (token) {
// Handle 401 Unauthorized errors by logging out and redirecting to login req = addToken(req, token);
if (error.status === 401) { }
authService.logout();
router.navigate(['/login']); 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);
} }
return throwError(() => error); return throwError(() => error);
}) })
); );
}
// If no token, just pass the request through
return next(request);
}; };
// Función para agregar token a la petición
function addToken(request: HttpRequest<unknown>, token: string): HttpRequest<unknown> {
return request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
// Función para manejar errores 401 (token expirado)
function handle401Error(req: HttpRequest<unknown>, next: HttpHandlerFn, authService: DirectAuthService) {
// Intentar refrescar el token solo si el usuario está activo
// El método refreshToken() ya maneja el caso de inactividad internamente
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);
})
);
}

View File

@ -0,0 +1,12 @@
<div class="p-4 flex justify-content-center align-items-center" style="min-height: 70vh;">
<div class="p-card p-shadow-4 p-4 text-center" style="max-width: 500px;">
<i class="pi pi-lock text-danger" style="font-size: 4rem;"></i>
<h2 class="mt-3 text-2xl font-bold">Acceso Denegado</h2>
<p class="mt-2 mb-4">No tienes los permisos necesarios para acceder a esta página.</p>
<p class="mb-4 text-sm">Tu rol actual no te permite ver este contenido.</p>
<button class="p-button p-button-primary mt-3" (click)="goHome()">
<i class="pi pi-home mr-2"></i>
Volver al inicio
</button>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AccessDeniedComponent } from './access-denied.component';
describe('AccessDeniedComponent', () => {
let component: AccessDeniedComponent;
let fixture: ComponentFixture<AccessDeniedComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AccessDeniedComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AccessDeniedComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,17 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
@Component({
selector: 'app-access-denied',
imports: [CommonModule],
templateUrl: './access-denied.component.html',
styleUrl: './access-denied.component.scss'
})
export class AccessDeniedComponent {
constructor(private router: Router) {}
goHome() {
this.router.navigate(['/inicio']);
}
}

View File

@ -0,0 +1,15 @@
<div class="p-4">
<div class="p-card p-shadow-4 p-4">
<h2 class="text-2xl font-bold mb-4">Área de Administrador</h2>
<div class="bg-blue-50 p-3 rounded border-left-3 border-blue-500 mb-3">
<i class="pi pi-info-circle text-blue-500 mr-2"></i>
Esta página solo es accesible para usuarios con rol de administrador.
</div>
<div class="mt-4">
<h3 class="text-xl font-medium mb-2">Información del usuario:</h3>
<pre class="p-2 border-1 border-gray-300 rounded bg-gray-100 overflow-auto">{{ userInfo | json }}</pre>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AdminAreaComponent } from './admin-area.component';
describe('AdminAreaComponent', () => {
let component: AdminAreaComponent;
let fixture: ComponentFixture<AdminAreaComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminAreaComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AdminAreaComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,20 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DirectAuthService } from '../../services/direct-auth.service';
@Component({
selector: 'app-admin-area',
imports: [CommonModule],
templateUrl: './admin-area.component.html',
styleUrl: './admin-area.component.scss'
})
export class AdminAreaComponent implements OnInit {
userInfo: any;
constructor(private authService: DirectAuthService) {}
ngOnInit() {
this.userInfo = this.authService.getCurrentUser();
}
}

View File

@ -10,10 +10,8 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Toast para mensajes --> <!-- Toast para mensajes -->
<p-toast></p-toast> <p-toast></p-toast>
<!-- MAIN CONTENT --> <!-- MAIN CONTENT -->
<main class="main-content flex align-items-center justify-content-center"> <main class="main-content flex align-items-center justify-content-center">
<div class="login-container"> <div class="login-container">
@ -23,99 +21,81 @@
<!-- PANEL DE LOGIN --> <!-- PANEL DE LOGIN -->
<div class="panel-container w-full" <div class="panel-container w-full"
[ngClass]="{'animate__animated animate__fadeOut d-none': showRecovery, [ngClass]="{'animate__animated animate__fadeOut d-none': showRecovery(),
'animate__animated animate__fadeIn': !showRecovery && !isInitialLoad}"> 'animate__animated animate__fadeIn': !showRecovery() && !isInitialLoad()}">
<div class="login-card shadow-2 border-round"> <div class="login-card shadow-2 border-round">
<div class="login-header"> <div class="login-header">
<h2>Iniciar Sesión</h2> <h2>Iniciar Sesión</h2>
</div> </div>
<form (ngSubmit)="onLogin()" class="p-3"> <form (ngSubmit)="onLogin()" class="p-3">
<!-- Email --> <!-- Email -->
<div class="field mb-3"> <div class="field mb-3">
<input type="email" pInputText [(ngModel)]="email" name="email" placeholder="Email" <input type="email" pInputText [(ngModel)]="email" name="email" placeholder="Email"
class="input-with-icon w-full" required /> class="input-with-icon w-full" required
(input)="clearErrors()"/>
</div> </div>
<!-- Password --> <!-- Password -->
<div class="field mb-3"> <div class="field mb-3">
<input type="password" pInputText [(ngModel)]="password" name="password" placeholder="Password" <input type="password" pInputText [(ngModel)]="password" name="password" placeholder="Password"
class="input-with-lock w-full" required /> class="input-with-lock w-full" required
(input)="clearErrors()"/>
</div> </div>
<!-- Mensaje de error --> <!-- Mensaje de error -->
<div *ngIf="errorMessage" class="error-message my-2"> <div *ngIf="errorMessage()" class="error-message my-2">
<p-message severity="error" [text]="errorMessage"></p-message> <p-message severity="error" [text]="errorMessage() || ''"></p-message>
</div> </div>
<!-- Botón --> <!-- Botón -->
<div class="login-actions"> <div class="login-actions">
<button pButton type="submit" [label]="loading ? 'Autenticando...' : 'Autenticar'" <button pButton type="submit" [label]="loading() ? 'Autenticando...' : 'Autenticar'"
class="p-button-primary w-full" [disabled]="loading || !email || !password"> class="p-button-primary w-full" [disabled]="loading() || !email || !password">
<i *ngIf="loading" class="pi pi-spin pi-spinner mr-2"></i> <i *ngIf="loading()" class="pi pi-spin pi-spinner mr-2"></i>
</button> </button>
</div> </div>
</form> </form>
<!-- Recuperar contraseña -->
<div class="password-recovery px-3 pb-3">
<a href="#" (click)="toggleRecovery($event)">Recuperar Contraseña</a>
</div>
<!-- Credenciales de prueba -->
<div class="test-credentials mx-3 mb-3 p-2 border-round bg-gray-100">
<p class="mb-1 font-bold">Credenciales de prueba:</p>
<p class="mb-1">Email: admin&#64;example.com</p>
<p>Password: admin123</p>
</div>
</div> </div>
</div> </div>
<!-- PANEL DE RECUPERACIÓN --> <!-- PANEL DE RECUPERACIÓN DE CONTRASEÑA -->
<div class="panel-container w-full" <div class="recovery-panel"
[ngClass]="{'animate__animated animate__fadeIn': showRecovery, [ngClass]="{'animate__animated animate__fadeIn': showRecovery(),
'animate__animated animate__fadeOut d-none': !showRecovery && !isInitialLoad}"> 'animate__animated animate__fadeOut d-none': !showRecovery()}">
<div class="login-card shadow-2 border-round"> <div class="login-card shadow-2 border-round">
<div class="login-header"> <div class="login-header">
<h2>Recuperar Contraseña</h2> <h2>Recuperar Contraseña</h2>
</div> </div>
<form (ngSubmit)="onRequestPasswordRecovery()" class="p-3">
<form (ngSubmit)="onRecoverPassword()" class="p-3"> <!-- Email de recuperación -->
<!-- Email para recuperación -->
<div class="field mb-3"> <div class="field mb-3">
<input type="email" pInputText [(ngModel)]="recoveryEmail" name="recoveryEmail" <input type="email" pInputText [(ngModel)]="recoveryEmail" name="recoveryEmail"
placeholder="Ingresa tu email" class="input-with-icon w-full" required /> placeholder="Ingresa tu email" class="input-with-icon w-full" required
(input)="clearErrors()"/>
</div> </div>
<!-- Mensaje de error -->
<!-- Mensaje informativo --> <div *ngIf="errorMessage()" class="error-message my-2">
<div class="info-message mb-3"> <p-message severity="error" [text]="errorMessage() || ''"></p-message>
<p class="text-sm">Te enviaremos un enlace para restablecer tu contraseña.</p>
</div> </div>
<!-- Botones -->
<!-- Mensaje de estado de recuperación -->
<div *ngIf="recoveryMessage" class="recovery-message my-2">
<p-message [severity]="recoveryStatus" [text]="recoveryMessage"></p-message>
</div>
<!-- Botón de enviar -->
<div class="login-actions"> <div class="login-actions">
<button pButton type="submit" [label]="recoveryLoading ? 'Enviando...' : 'Enviar Enlace'" <button pButton type="submit"
class="p-button-primary w-full" [disabled]="recoveryLoading || !recoveryEmail"> [label]="loading() ? 'Enviando...' : 'Enviar Instrucciones'"
<i *ngIf="recoveryLoading" class="pi pi-spin pi-spinner mr-2"></i> class="p-button-primary w-full mb-2"
[disabled]="loading() || !recoveryEmail">
<i *ngIf="loading()" class="pi pi-spin pi-spinner mr-2"></i>
</button>
<button pButton type="button"
label="Volver al Login"
class="p-button-outlined p-button-secondary w-full"
(click)="toggleRecovery($event)"
[disabled]="loading()">
</button> </button>
</div> </div>
</form> </form>
</div>
</div>
<!-- Volver al login -->
<div class="password-recovery px-3 pb-3">
<a href="#" (click)="toggleRecovery($event)">Volver al Login</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<!-- FOOTER --> <!-- FOOTER -->
<app-footer></app-footer> <app-footer></app-footer>

View File

@ -1,136 +1,161 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, signal } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { DirectAuthService } from '../../services/direct-auth.service';
import { CardModule } from 'primeng/card'; import { MessageService } from 'primeng/api';
import { InputTextModule } from 'primeng/inputtext';
// PrimeNG Components
import { ButtonModule } from 'primeng/button'; import { ButtonModule } from 'primeng/button';
import { PasswordModule } from 'primeng/password'; import { InputTextModule } from 'primeng/inputtext';
import { DividerModule } from 'primeng/divider';
import { MessagesModule } from 'primeng/messages';
import { MessageModule } from 'primeng/message'; import { MessageModule } from 'primeng/message';
import { ToastModule } from 'primeng/toast'; import { ToastModule } from 'primeng/toast';
import { MessageService } from 'primeng/api'; import { FooterComponent } from '../../components/footer/footer.component';
import { FooterComponent } from "../../components/footer/footer.component";
import { AuthService } from '../../services/auth.service';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
CardModule,
InputTextModule,
ButtonModule, ButtonModule,
PasswordModule, InputTextModule,
DividerModule,
MessagesModule,
MessageModule, MessageModule,
ToastModule, ToastModule,
FooterComponent FooterComponent
], ],
providers: [MessageService],
templateUrl: './login.component.html', templateUrl: './login.component.html',
styleUrl: './login.component.scss' styleUrls: ['./login.component.scss']
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
// Login form // Variables de modelo
email: string = ''; email: string = '';
password: string = ''; password: string = '';
loading: boolean = false;
errorMessage: string = '';
// Password recovery form // Estados con signals
loading = signal<boolean>(false);
errorMessage = signal<string | null>(null);
showRecovery = signal<boolean>(false);
isInitialLoad = signal<boolean>(true);
// Credenciales para recuperación
recoveryEmail: string = ''; recoveryEmail: string = '';
recoveryLoading: boolean = false;
recoveryMessage: string = '';
recoveryStatus: string = 'info';
// Control de formularios // URL para redirección después del login
showRecovery: boolean = false; private returnUrl: string = '/inicio';
isInitialLoad: boolean = false;
constructor( constructor(
private authService: DirectAuthService,
private router: Router, private router: Router,
private authService: AuthService, private route: ActivatedRoute,
private messageService: MessageService private messageService: MessageService
) { } ) { }
ngOnInit() { ngOnInit(): void {
// Obtener URL de retorno de los parámetros de query
this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/inicio';
} // Comprobar si ya hay sesión activa
if (this.authService.isAuthenticated()) {
/** this.router.navigate([this.returnUrl]);
* Cambia entre el formulario de login y recuperación
*/
toggleRecovery(event: Event) {
event.preventDefault();
this.showRecovery = !this.showRecovery;
this.errorMessage = '';
this.recoveryMessage = '';
// Si estamos cambiando al formulario de recuperación, copiar el email actual
if (this.showRecovery && this.email) {
this.recoveryEmail = this.email;
}
// Forzar actualización del DOM con un pequeño retraso
setTimeout(() => {
// Este timeout ayuda a que Angular aplique los cambios de clase completamente
}, 50); // Aumentado para asegurar la transición suave
}
/**
* Proceso de login
*/
onLogin() {
this.loading = true;
this.errorMessage = '';
// Llamar al servicio auth
this.authService.login({ email: this.email, password: this.password })
.subscribe({
next: () => {
console.log('Login exitoso');
this.loading = false;
this.router.navigate(['/inicio']);
},
error: (error) => {
console.error('Error en login:', error);
this.loading = false;
this.errorMessage = 'Credenciales incorrectas';
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: 'Credenciales incorrectas'
});
}
});
}
/**
* Proceso de recuperación de contraseña
*/
onRecoverPassword() {
if (!this.recoveryEmail) {
this.recoveryMessage = 'Debes ingresar un email';
this.recoveryStatus = 'error';
return; return;
} }
this.recoveryLoading = true; // Marcar que ya no es carga inicial (para animaciones)
this.recoveryMessage = '';
// Simulamos la recuperación (en producción, esto llamaría a un servicio real)
setTimeout(() => { setTimeout(() => {
this.recoveryLoading = false; this.isInitialLoad.set(false);
this.recoveryMessage = 'Hemos enviado un enlace de recuperación a tu email'; }, 100);
this.recoveryStatus = 'success'; }
/**
* 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;
}
this.loading.set(true);
this.errorMessage.set(null);
this.authService.login(this.email, this.password)
.subscribe({
next: () => {
// Mostrar mensaje de éxito
this.messageService.add({ this.messageService.add({
severity: 'success', severity: 'success',
summary: 'Email enviado', summary: 'Bienvenido',
detail: 'Se ha enviado un enlace de recuperación a tu correo' 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); }, 1500);
} }
/**
* Limpia errores cuando el usuario comienza a escribir
*/
clearErrors(): void {
if (this.errorMessage()) {
this.errorMessage.set(null);
}
}
} }

View File

@ -0,0 +1,14 @@
<div class="p-4">
<div class="p-card p-shadow-4 p-4">
<h2 class="text-2xl font-bold mb-4">Área de Usuario</h2>
<div class="bg-green-50 p-3 rounded border-left-3 border-green-500 mb-3">
<i class="pi pi-info-circle text-green-500 mr-2"></i>
Esta página es accesible para usuarios con roles estándar.
</div>
<div class="mt-4">
<h3 class="text-xl font-medium mb-2">Información del usuario:</h3>
<pre class="p-2 border-1 border-gray-300 rounded bg-gray-100 overflow-auto">{{ userInfo | json }}</pre>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserAreaComponent } from './user-area.component';
describe('UserAreaComponent', () => {
let component: UserAreaComponent;
let fixture: ComponentFixture<UserAreaComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserAreaComponent]
})
.compileComponents();
fixture = TestBed.createComponent(UserAreaComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DirectAuthService } from '../../services/direct-auth.service';
@Component({
selector: 'app-user-area',
imports: [CommonModule],
templateUrl: './user-area.component.html',
styleUrl: './user-area.component.scss'
})
export class UserAreaComponent implements OnInit {
userInfo: any;
constructor(private authService: DirectAuthService) {}
ngOnInit() {
this.userInfo = this.authService.getCurrentUser();
}
}

View File

@ -1,111 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject, of, throwError } from 'rxjs';
import { tap, delay } from 'rxjs/operators';
interface LoginCredentials {
username?: string;
password?: string;
email?: string;
}
interface AuthResponse {
token: string;
user: {
id: number;
username: string;
name: string;
role: string;
email?: string;
};
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private apiUrl = 'api/auth';
private userSubject = new BehaviorSubject<any>(null);
public user$ = this.userSubject.asObservable();
// Usuarios de prueba para simular login
private mockUsers = [
{
email: 'admin@example.com',
password: 'admin123',
user: {
id: 1,
username: 'admin',
name: 'Administrador',
role: 'admin',
email: 'admin@example.com'
}
},
{
email: 'user@example.com',
password: 'user123',
user: {
id: 2,
username: 'user',
name: 'Usuario',
role: 'user',
email: 'user@example.com'
}
}
];
constructor(private http: HttpClient) {
// Check if user is already logged in on service initialization
const user = localStorage.getItem('user');
if (user) {
this.userSubject.next(JSON.parse(user));
}
}
login(credentials: LoginCredentials): Observable<AuthResponse> {
// Simular login con usuarios de prueba
const user = this.mockUsers.find(u =>
u.email === credentials.email && u.password === credentials.password);
if (user) {
// Generar token falso
const mockResponse: AuthResponse = {
token: 'mock-jwt-token-' + Math.random().toString(36).substring(2, 15),
user: user.user
};
return of(mockResponse).pipe(
// Simular retraso de red
delay(800),
tap(response => {
// Store token and user info
localStorage.setItem('token', response.token);
localStorage.setItem('user', JSON.stringify(response.user));
this.userSubject.next(response.user);
})
);
} else {
// Simular error de credenciales inválidas
return throwError(() => new Error('Credenciales incorrectas'));
}
}
logout(): void {
// Clear storage and update subject
localStorage.removeItem('token');
localStorage.removeItem('user');
this.userSubject.next(null);
}
isLoggedIn(): boolean {
return !!localStorage.getItem('token');
}
getToken(): string | null {
return localStorage.getItem('token');
}
getCurrentUser(): any {
return this.userSubject.value;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
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'
},
auth: {
// Tiempo mínimo de inactividad en milisegundos (5 minutos para producción)
minInactivityTime: 5 * 60 * 1000,
// Porcentaje del tiempo de vida del token para considerar inactividad (90%)
inactivityPercentage: 0.9,
// Estricto: true = no renovar si inactivo, false = renovar siempre
strictTokenRenewal: true,
// Intervalo de log en milisegundos (desactivado en producción)
logInterval: 0,
// Deshabilitar logs de inactividad en producción
enableActivityLogs: false
}
};

View File

@ -0,0 +1,26 @@
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'
},
auth: {
// Tiempo mínimo de inactividad en milisegundos (30 segundos para desarrollo)
minInactivityTime: 30 * 1000,
// Porcentaje del tiempo de vida del token para considerar inactividad (90%)
inactivityPercentage: 0.9,
// Estricto: true = no renovar si inactivo, false = renovar siempre
strictTokenRenewal: true,
// Intervalo de log en milisegundos (1 segundo)
logInterval: 2000,
// Habilitar logs de inactividad
enableActivityLogs: false
}
};

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>cronogramas</title> <title>correo</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">

File diff suppressed because it is too large Load Diff