¡🚀 Lleva tu App Angular al Siguiente Nivel! Implementando Autenticación y Autorización Robusta con JWT
Este tutorial te guiará paso a paso en la implementación de un sistema robusto de autenticación y autorización en aplicaciones Angular utilizando JSON Web Tokens (JWT). Aprenderás desde la configuración inicial hasta la protección de rutas y la gestión de tokens, asegurando una experiencia segura y eficiente.
La seguridad es un pilar fundamental en el desarrollo de cualquier aplicación web moderna. Garantizar que solo los usuarios autorizados accedan a recursos específicos no es una opción, sino una necesidad. En el ecosistema de Angular, la implementación de la autenticación y autorización puede parecer desafiante al principio, pero con las herramientas y estrategias adecuadas, se convierte en un proceso claro y manejable.
Este tutorial se sumerge en el corazón de la autenticación y autorización en Angular utilizando JSON Web Tokens (JWT), el estándar de la industria. Aprenderás a configurar tu aplicación para interactuar con una API segura, gestionar los tokens de forma eficiente y proteger tus rutas de manera inteligente.
¿Por qué JWT para Autenticación y Autorización en Angular? 🤔
JSON Web Tokens (JWT) se ha convertido en la opción preferida para la autenticación en aplicaciones modernas, especialmente en Single Page Applications (SPAs) como las construidas con Angular. Pero, ¿qué hace que JWT sea tan popular?
- Compacto y autosuficiente: Los JWT son compactos y se envían a través de la URL, el POST parámetro o dentro de una cabecera HTTP. Son autosuficientes porque contienen toda la información necesaria sobre el usuario (claims).
- Seguro: Están firmados digitalmente (usando un secreto o un par de claves pública/privada), lo que asegura su integridad y autenticidad. Esto significa que una vez firmado, el token no puede ser alterado por un tercero sin invalidarse.
- Estándar de la industria: Es un estándar abierto (RFC 7519) que es compatible con una amplia gama de lenguajes y plataformas, facilitando la integración con diferentes backends.
- Sin estado (Stateless): El servidor no necesita almacenar información de sesión. Cada solicitud del cliente, si incluye un JWT válido, puede ser verificada independientemente, lo que es ideal para arquitecturas de microservicios y escalabilidad.
Arquitectura de Autenticación con JWT 🏗️
Antes de sumergirnos en el código, es útil entender el flujo general de autenticación con JWT en una aplicación Angular.
Flujo Básico de Autenticación:
- Inicio de Sesión: El usuario introduce sus credenciales (nombre de usuario/email y contraseña) en la interfaz de usuario de Angular.
- Solicitud de Credenciales: La aplicación Angular envía estas credenciales a un endpoint de autenticación en el servidor (API Backend).
- Generación de JWT: El servidor verifica las credenciales. Si son válidas, genera un JWT que contiene información sobre el usuario (claims) y lo firma con una clave secreta.
- Respuesta al Cliente: El servidor envía este JWT de vuelta a la aplicación Angular.
- Almacenamiento del JWT: La aplicación Angular almacena el JWT, generalmente en
localStorageosessionStorage. - Acceso a Recursos Protegidos: Para acceder a cualquier recurso protegido en el servidor, la aplicación Angular incluye el JWT en la cabecera
Authorization(comoBearer <token>) de cada solicitud HTTP. - Verificación del JWT: El servidor intercepta la solicitud, verifica la firma del JWT, y si es válido, permite el acceso al recurso solicitado.
🛠️ Configuración Inicial del Proyecto Angular
Antes de empezar a codificar, asegúrate de tener un proyecto Angular funcional. Si no, puedes crearlo con:
ng new angular-auth-jwt --routing --style=scss
cd angular-auth-jwt
Dependencias Necesarias
Para trabajar con JWT en el lado del cliente, es útil tener una librería para decodificar los tokens si necesitas acceder a los claims en el frontend (solo para fines de visualización o lógica de UI, no de seguridad).
npm install @auth0/angular-jwt
Esta librería es opcional pero muy recomendada para manejar JWT de manera más conveniente en Angular.
📦 Paso 1: Creación de Servicios de Autenticación
Crearemos un servicio AuthService que será el encargado de manejar el inicio de sesión, el registro (opcional, si lo incluyes), el almacenamiento/recuperación del token y la verificación del estado de autenticación.
Genera el servicio:
ng generate service auth/auth
src/app/auth/auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { Router } from '@angular/router';
import { JwtHelperService } from '@auth0/angular-jwt';
interface AuthResponse {
token: string;
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly TOKEN_KEY = 'jwt_token';
private apiUrl = 'http://localhost:3000/api/auth'; // Reemplaza con la URL de tu API de autenticación
private loggedIn = new BehaviorSubject<boolean>(this.hasToken());
constructor(
private http: HttpClient,
private router: Router,
private jwtHelper: JwtHelperService
) { }
get isLoggedIn(): Observable<boolean> {
return this.loggedIn.asObservable();
}
private hasToken(): boolean {
const token = localStorage.getItem(this.TOKEN_KEY);
return !!token && !this.jwtHelper.isTokenExpired(token);
}
login(credentials: any): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${this.apiUrl}/login`, credentials)
.pipe(
tap(response => {
this.saveToken(response.token);
this.loggedIn.next(true);
}),
catchError(error => {
console.error('Login failed:', error);
// Aquí puedes agregar lógica para manejar errores, como mostrar un mensaje al usuario
throw error;
})
);
}
register(user: any): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${this.apiUrl}/register`, user)
.pipe(
tap(response => {
this.saveToken(response.token);
this.loggedIn.next(true);
}),
catchError(error => {
console.error('Registration failed:', error);
throw error;
})
);
}
logout(): void {
localStorage.removeItem(this.TOKEN_KEY);
this.loggedIn.next(false);
this.router.navigate(['/login']); // Redirigir a la página de login después de cerrar sesión
}
saveToken(token: string): void {
localStorage.setItem(this.TOKEN_KEY, token);
}
getToken(): string | null {
return localStorage.getItem(this.TOKEN_KEY);
}
getUserFromToken(): any {
const token = this.getToken();
return token ? this.jwtHelper.decodeToken(token) : null;
}
// Comprueba si el token es válido y no ha expirado
isAuthenticated(): boolean {
const token = this.getToken();
return !!token && !this.jwtHelper.isTokenExpired(token);
}
}
Configuración de JwtModule
Para que JwtHelperService funcione, necesitamos configurarlo en app.module.ts. También debemos asegurarnos de que HttpClientModule esté importado.
// src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { JwtModule } from '@auth0/angular-jwt';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; // Para formularios
// Función para obtener el token del localStorage
export function tokenGetter() {
return localStorage.getItem('jwt_token');
}
@NgModule({
declarations: [
AppComponent,
// Tus componentes aquí (Login, Register, Dashboard, etc.)
],
imports: [
BrowserModule,
AppRoutingModule,
HttpClientModule,
FormsModule,
ReactiveFormsModule,
JwtModule.forRoot({
config: {
tokenGetter: tokenGetter,
allowedDomains: ['localhost:3000'], // Reemplaza con tus dominios de API
disallowedRoutes: [], // Rutas a las que no se debe añadir el token
}
})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
🛡️ Paso 2: Interceptor de Token JWT
Para enviar automáticamente el JWT en cada solicitud HTTP a tu API protegida, usaremos un HttpInterceptor. Esto evita tener que añadir manualmente el token en cada servicio o componente.
Genera el interceptor:
ng generate interceptor auth/token
src/app/auth/token.interceptor.ts
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
@Injectable()
export class TokenInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = this.authService.getToken();
if (token) {
// Clona la solicitud y añade la cabecera de autorización
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next.handle(request);
}
}
Registra el Interceptor en app.module.ts
// src/app/app.module.ts
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { TokenInterceptor } from './auth/token.interceptor';
@NgModule({
// ...
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: TokenInterceptor,
multi: true
}
],
// ...
})
export class AppModule { }
🔒 Paso 3: Protección de Rutas con Guards de Autenticación
Los Guards de Angular son fundamentales para implementar la autorización a nivel de ruta. Crearemos un AuthGuard para evitar que usuarios no autenticados accedan a ciertas partes de tu aplicación.
Genera el Guard:
ng generate guard auth/auth
src/app/auth/auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
import { tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (this.authService.isAuthenticated()) {
return true;
} else {
// No autenticado, redirigir a la página de login
this.router.navigate(['/login']);
return false;
}
}
}
Aplicando el Guard a las Rutas
Ahora, puedes aplicar este AuthGuard a las rutas que deseas proteger en app-routing.module.ts.
// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './auth/auth.guard'; // Importa tu guard
// Componentes de ejemplo (debes crearlos)
import { LoginComponent } from './auth/login/login.component';
import { RegisterComponent } from './auth/register/register.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { HomeComponent } from './home/home.component';
const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuard] },
// Otras rutas protegidas...
{ path: '**', redirectTo: '' } // Redirigir rutas no encontradas
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
📝 Paso 4: Componentes de Login y Logout
Creemos rápidamente un componente de Login para interactuar con nuestro AuthService.
ng generate component auth/login
src/app/auth/login/login.component.ts
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AuthService } from '../auth.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent {
loginForm: FormGroup;
errorMessage: string | null = null;
constructor(
private fb: FormBuilder,
private authService: AuthService,
private router: Router
) {
this.loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]]
});
}
onSubmit(): void {
this.errorMessage = null;
if (this.loginForm.valid) {
this.authService.login(this.loginForm.value).subscribe({
next: () => {
this.router.navigate(['/dashboard']); // Redirigir al dashboard
},
error: (err) => {
this.errorMessage = 'Credenciales inválidas. Inténtalo de nuevo.';
console.error('Login error:', err);
}
});
} else {
this.errorMessage = 'Por favor, introduce un email y una contraseña válidos.';
}
}
}
src/app/auth/login/login.component.html
<div class="login-container">
<h2>Iniciar Sesión</h2>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="email">Email:</label>
<input id="email" type="email" formControlName="email" placeholder="tu@email.com">
<div *ngIf="loginForm.get('email')?.invalid && (loginForm.get('email')?.dirty || loginForm.get('email')?.touched)" class="error-message">
<span *ngIf="loginForm.get('email')?.errors?.['required']">El email es obligatorio.</span>
<span *ngIf="loginForm.get('email')?.errors?.['email']">Introduce un email válido.</span>
</div>
</div>
<div class="form-group">
<label for="password">Contraseña:</label>
<input id="password" type="password" formControlName="password" placeholder="********">
<div *ngIf="loginForm.get('password')?.invalid && (loginForm.get('password')?.dirty || loginForm.get('password')?.touched)" class="error-message">
<span *ngIf="loginForm.get('password')?.errors?.['required']">La contraseña es obligatoria.</span>
<span *ngIf="loginForm.get('password')?.errors?.['minlength']">La contraseña debe tener al menos 6 caracteres.</span>
</div>
</div>
<div *ngIf="errorMessage" class="error-message auth-error">{{ errorMessage }}</div>
<button type="submit" [disabled]="loginForm.invalid">Iniciar Sesión</button>
</form>
<p>¿No tienes cuenta? <a routerLink="/register">Regístrate aquí</a></p>
</div>
Para el logout, puedes tener un botón en tu barra de navegación o en un componente de perfil que simplemente llame a authService.logout(). Por ejemplo, en app.component.ts:
import { Component, OnInit } from '@angular/core';
import { AuthService } from './auth/auth.service';
import { Observable } from 'rxjs';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
title = 'angular-auth-jwt';
isLoggedIn$: Observable<boolean>;
constructor(private authService: AuthService) {
this.isLoggedIn$ = this.authService.isLoggedIn;
}
ngOnInit(): void {
// Esto asegura que el estado de login se inicialice correctamente al cargar la app
// si el token ya existe en localStorage
}
logout(): void {
this.authService.logout();
}
}
<!-- src/app/app.component.html -->
<nav>
<a routerLink="/">Inicio</a>
<ng-container *ngIf="isLoggedIn$ | async; else loggedOut">
<a routerLink="/dashboard">Dashboard</a>
<button (click)="logout()">Cerrar Sesión</button>
</ng-container>
<ng-template #loggedOut>
<a routerLink="/login">Iniciar Sesión</a>
<a routerLink="/register">Registrarse</a>
</ng-template>
</nav>
<router-outlet></router-outlet>
🔑 Paso 5: Protección de Componentes y Datos Sensibles (Autorización)
La autorización va más allá de saber si un usuario está autenticado; se trata de saber qué puede hacer un usuario. JWTs son excelentes para esto porque pueden incluir claims (cargas útiles) que describen los roles o permisos del usuario.
Por ejemplo, un JWT podría tener un claim roles: ['admin', 'editor'].
Acceso basado en Roles/Permisos
Podemos extender nuestro AuthGuard o crear un RoleGuard para verificar roles.
Genera un RoleGuard:
ng generate guard auth/role
src/app/auth/role.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
@Injectable({
providedIn: 'root'
})
export class RoleGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
const expectedRoles = route.data['roles'] as Array<string>;
if (!expectedRoles) {
return true; // Si no hay roles esperados, no se requiere verificación de rol específica
}
const user = this.authService.getUserFromToken();
if (this.authService.isAuthenticated() && user && user.roles && expectedRoles.some(role => user.roles.includes(role))) {
return true;
} else {
console.warn('Acceso denegado: El usuario no tiene los roles requeridos o no está autenticado.');
this.router.navigate(['/access-denied']); // O redirige a login o a una página de error
return false;
}
}
}
Aplicando RoleGuard a las Rutas
// src/app/app-routing.module.ts
// ... (importaciones)
import { RoleGuard } from './auth/role.guard';
// Componentes de ejemplo (debes crearlos)
import { AdminDashboardComponent } from './admin/admin-dashboard/admin-dashboard.component';
import { AccessDeniedComponent } from './shared/access-denied/access-denied.component';
const routes: Routes = [
// ...
{
path: 'admin',
component: AdminDashboardComponent,
canActivate: [AuthGuard, RoleGuard],
data: { roles: ['admin'] } // Se requiere el rol 'admin'
},
{
path: 'editor',
component: AdminDashboardComponent,
canActivate: [AuthGuard, RoleGuard],
data: { roles: ['admin', 'editor'] } // Se requiere el rol 'admin' o 'editor'
},
{ path: 'access-denied', component: AccessDeniedComponent },
// ...
];
🔄 Paso 6: Refresh Tokens (Opcional pero Recomendado)
Los JWT tienen una vida útil corta por razones de seguridad. Esto significa que el usuario tendrá que iniciar sesión de nuevo con frecuencia. Los Refresh Tokens resuelven esto.
El proceso es el siguiente:
- Cuando el usuario inicia sesión, el servidor emite un Access Token (el JWT normal, de corta duración) y un Refresh Token (un token de larga duración).
- El Access Token se usa para las solicitudes API. Cuando expira, el cliente usa el Refresh Token para solicitar un nuevo Access Token al servidor, sin necesidad de que el usuario vuelva a iniciar sesión.
- El Refresh Token también debe ser invalidado por el servidor si el usuario cierra sesión o si se detecta actividad sospechosa.
Modificaciones para Refresh Tokens
- Backend: Tu API debe tener un endpoint para
'/refresh'que acepte el Refresh Token y devuelva un nuevo Access Token (y opcionalmente un nuevo Refresh Token). También debe manejar la invalidación de Refresh Tokens. - AuthService: Necesitarías añadir métodos para guardar y usar el Refresh Token. El
logindevolvería ambos tokens. - TokenInterceptor: Si un Access Token expira, el interceptor podría detectar un error 401, intentar usar el Refresh Token para obtener uno nuevo y luego reintentar la solicitud original. Esto se implementa con
catchErroryswitchMapde RxJS.
Este patrón de refresco puede ser complejo de implementar correctamente, especialmente el manejo de reintentos de solicitudes y condiciones de carrera. Librerías como @auth0/angular-jwt no lo gestionan directamente y requeriría una lógica personalizada en tu interceptor.
// Ejemplo simplificado de cómo el interceptor podría manejar un refresh (requiere endpoint de refresh en backend)
// src/app/auth/token.interceptor.ts (fragmento)
import { HttpErrorResponse } from '@angular/common/http';
import { throwError, BehaviorSubject, Observable } from 'rxjs';
import { catchError, filter, take, switchMap } from 'rxjs/operators';
// ... dentro de TokenInterceptor
private isRefreshing = false;
private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const token = this.authService.getToken();
if (token) {
request = this.addToken(request, token);
}
return next.handle(request).pipe(
catchError(error => {
if (error instanceof HttpErrorResponse && error.status === 401 && this.authService.isAuthenticated()) {
// Error 401, token expirado o inválido. Intentar refrescar
return this.handle401Error(request, next);
}
return throwError(error);
})
);
}
private addToken(request: HttpRequest<any>, token: string) {
return request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
private handle401Error(request: HttpRequest<any>, next: HttpHandler) {
if (!this.isRefreshing) {
this.isRefreshing = true;
this.refreshTokenSubject.next(null);
// Aquí llamarías a tu AuthService para refrescar el token
return this.authService.refreshToken().pipe(
switchMap((token: any) => {
this.isRefreshing = false;
this.refreshTokenSubject.next(token.accessToken); // Asume que refreshToken() devuelve un objeto con accessToken
return next.handle(this.addToken(request, token.accessToken));
}),
catchError((err) => {
this.isRefreshing = false;
this.authService.logout(); // Si el refresh falla, cerrar sesión
return throwError(err);
})
);
} else {
return this.refreshTokenSubject.pipe(
filter(token => token != null),
take(1),
switchMap(jwt => {
return next.handle(this.addToken(request, jwt));
})
);
}
}
Consideraciones Adicionales sobre Refresh Tokens
Seguridad del Refresh Token: Dado que los Refresh Tokens tienen una vida útil más larga, son un objetivo más valioso para los atacantes. Se recomienda encarecidamente almacenarlos en `HttpOnly cookies` si tu backend y frontend están en el mismo dominio. Esto evita que JavaScript acceda directamente al token, mitigando ataques XSS.
Rotación de Refresh Tokens: Una práctica de seguridad avanzada es rotar los Refresh Tokens. Cada vez que se usa un Refresh Token para obtener un nuevo Access Token, el servidor también emite un nuevo Refresh Token y el anterior se invalida. Esto limita el daño si un Refresh Token es robado, ya que el atacante solo tendría un uso válido del token.
Lista Negra de Tokens: Para escenarios donde necesitas revocar un Refresh Token (por ejemplo, si el usuario cierra sesión en todos los dispositivos o se detecta un compromiso), el servidor debe mantener una lista negra de tokens revocados. Esto es un contraste con la naturaleza *stateless* de los Access Tokens, pero es una concesión necesaria para la seguridad de los Refresh Tokens.
📊 Resumen de Puntos Clave
Aquí tienes un resumen rápido de los componentes y su propósito:
| Componente | Propósito | Archivo clave |
|---|---|---|
| --- | --- | --- |
AuthService | Gestiona el login, logout, almacenamiento/recuperación de JWT, estado de auth. | auth.service.ts |
TokenInterceptor | Añade automáticamente el JWT a las cabeceras Authorization de las solicitudes HTTP. | token.interceptor.ts |
| --- | --- | --- |
AuthGuard | Protege rutas, permitiendo el acceso solo a usuarios autenticados. | auth.guard.ts |
RoleGuard (Opcional) | Protege rutas basadas en los roles o permisos del usuario, extraídos del JWT. | role.guard.ts |
| --- | --- | --- |
JwtModule | Configuración de la librería @auth0/angular-jwt para decodificación y gestión de tokens. | app.module.ts |
LoginComponent | Interfaz para que el usuario introduzca credenciales y realice el login. | login.component.ts/html/scss |
Conclusión ✨
Implementar un sistema de autenticación y autorización robusto es un paso crítico para construir aplicaciones Angular seguras y escalables. Utilizando JSON Web Tokens (JWT) junto con los servicios, interceptores y guards de Angular, hemos construido una base sólida que protege tus recursos y gestiona el acceso de los usuarios de manera eficiente.
Recuerda siempre mantener las buenas prácticas de seguridad, como usar HTTPS, mantener actualizadas tus dependencias y nunca almacenar información sensible directamente en el lado del cliente o en los claims del JWT. La seguridad es un proceso continuo que requiere atención constante.
¡Felicidades, tu aplicación Angular ahora está un paso más cerca de ser completamente segura! 🎉
Tutoriales relacionados
- Gestión de Estado Reactiva con NgRx en Angular: Una Guía Completaintermediate15 min
- Optimización de Rendimiento en Aplicaciones Angular: Estrategias Avanzadas para una Experiencia Ultra Rápidaadvanced25 min
- Componentes de Contenido Reutilizables en Angular: ¡Crea Módulos Compartidos y Bibliotecas!intermediate20 min
- ¡🚀 Despliega tu App Angular! Guía Completa de Build, Optimización y Publicación en Producciónintermediate18 min
- Desarrollando Micro Frontends con Angular: Guía Completa de Módulos Federados y Monoreposadvanced25 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!