Guards de rutas
src/app/core/guards/ contiene cuatro guards canActivate. Tres son clases (AuthGuard, AdminGuard, LoggedInAuthGuard, todas providedIn: 'root') y uno es una función (permissionGuard, CanActivateFn). No hay canMatch, canDeactivate, canActivateChild ni resolvers en el proyecto.
Contexto relacionado: Auth, interceptores y RBAC (modelo de permisos), Rutas y layouts (árbol de rutas) y Interceptores (el otro mecanismo que reacciona a una sesión caída).
Resumen
| Guard | Archivo | Tipo | Pregunta que responde | Si falla |
|---|---|---|---|---|
AuthGuard | auth.guard.ts | clase, síncrono | ¿hay un JWT vigente en el almacenamiento? | userLogout + /auth/login (salvo iframe) |
AdminGuard | admin.guard.ts | clase, Observable<boolean> | ¿el userType del perfil es admin? | /dashboard |
permissionGuard | permission.guard.ts | función, Observable<boolean> | ¿el acrónimo de data.permission está en el conjunto del usuario? | /profile/profile-detail (ruta inexistente, ver gotchas) |
LoggedInAuthGuard | loggedIn.guard.ts | clase, síncrono | ¿el usuario ya está logueado? (guard inverso) | / |
AuthGuard
canActivate(): boolean
AuthService.isLoggedOut()es!isLoggedIn().isLoggedIn()leeUSER_SESSIONdesecureStorage, decodifica el JWT conStorageService.parseJwty comparaexp * 1000con la hora actual. Sin sesión guardada devuelvefalse.- Si la sesión no está vencida, devuelve
true. - Si está vencida y
IframeDetectionService.isIframe()estrue, devuelvetrueigualmente: en modo embebido se espera el handshakepostMessage(ver Frame bridge) en lugar de expulsar al usuario. - En cualquier otro caso despacha
UserActions.userLogout(), ejecutarouter.navigate(['/auth/login'])y devuelvefalse.
Solo valida expiración local: no verifica la firma del JWT ni consulta al backend. Un token revocado pasa el guard y la primera petición HTTP devolverá 401, que resuelve el interceptor de errores.
Dónde se usa: shared/routes/content.routes.ts en todas las rutas hijas del ContentComponent (con o sin permissionGuard) y, de nuevo, en features/settings/settings-routing.module.ts. Las rutas bajo FullComponent (auth) no lo llevan.
Spec: auth.guard.spec.ts (5 casos: permite con sesión, bloquea y redirige a /auth/login sin sesión, despacha userLogout, no redirige si hay sesión). El caso de iframe no tiene spec; el test lo simula con isIframe: () => false.
AdminGuard
canActivate(): Observable<boolean>
Lee GlobalService.profile (un ReplaySubject<User>(1)), toma el primer valor (take(1)), normaliza user?.userType con toString().trim().toLowerCase() y lo compara con TYPES_USER_PERMISSIONS.ADMIN ('admin'). Si no coincide, ejecuta router.navigate(['/dashboard']) y emite false.
- La comparación es insensible a mayúsculas:
Admintambién pasa (cubierto por spec). - Un perfil sin
userTypese trata como''y se deniega. - Tipos como
atc,agent,collaboratorAgentocollaboratorOperatorse deniegan.
Dónde se usa: solo en features/settings/settings-routing.module.ts, junto a AuthGuard y permissionGuard (canActivate: [AuthGuard, AdminGuard, permissionGuard]).
Spec: admin.guard.spec.ts (6 casos, incluida la comparación sin distinguir mayúsculas y la redirección a /dashboard).
:::caution Espera el perfil
ReplaySubject no emite hasta que get-me responde. Tras un refresco de página el guard queda pendiente hasta que loadUserSession carga el perfil. Si el perfil nunca llega (por ejemplo, iframe sin sesión, donde AuthGuard deja pasar), la navegación a settings queda colgada en lugar de denegarse.
:::
permissionGuard
export const permissionGuard: CanActivateFn = (route) => Observable<boolean>
Lee route.data['permission'] (un único acrónimo, p. ej. 'CONTACT_READ') y llama a MyPermissionsService.hasPermission(acronym):
- Si devuelve
true, deja pasar. - Si devuelve
false, ejecutarouter.navigate(['/profile/profile-detail'])y emitefalse. - Si el observable lanza un error (
catchError), hace lo mismo que en la denegación: redirige y emitefalse. Así un fallo al resolver permisos nunca deja el router con la pantalla en blanco.
MyPermissionsService resuelve contra una caché con TTL de 10 s (PERMISSIONS_TTL_MS, ver Auth y RBAC): si el conjunto está obsoleto vuelve a llamar a getMeUser(), y si esa llamada falla queda en conjunto vacío (todo denegado).
Detalles de contrato:
- Solo admite un acrónimo. No hay variante "cualquiera de" para rutas; esa semántica existe solo en la directiva
*appShowWithPermission(ver Directivas). - Si una ruta olvida declarar
data.permission,route.data['permission']esundefined,hasPermission(undefined)devuelvefalsey la ruta se deniega siempre.
Rutas protegidas
El guard se usa en dos niveles. En content.routes.ts va combinado con AuthGuard, y los módulos lazy repiten permissionGuard (con el mismo acrónimo) en sus propios *-routing.module.ts. La segunda comprobación es casi gratuita gracias a la caché.
Ruta (PAGE_URLS) | Guards en content.routes.ts | data.permission |
|---|---|---|
dashboard | AuthGuard | sin permiso |
whaticket | AuthGuard | sin permiso en la ruta raíz; whaticket/chat exige TICKET_READ en su módulo |
internal-chat | AuthGuard | sin permiso en la raíz; internal-chat/conversations exige INTERNAL_CHAT_READ en su módulo |
profile | AuthGuard | sin permiso |
access-control/roles, permissions, role-permissions, modules, permissions-builder | AuthGuard, permissionGuard | ACCESS_CONTROL_READ |
labels | AuthGuard, permissionGuard | LABEL_READ |
scheduled-messages | AuthGuard, permissionGuard | MESSAGE_READ (reutiliza el acrónimo de mensajes) |
locked-tickets | AuthGuard, permissionGuard | TICKET_UNLOCK_ANY |
quick-responses | AuthGuard, permissionGuard | QUICK_RESPONSE_READ |
contacts | AuthGuard, permissionGuard | CONTACT_READ |
users | AuthGuard, permissionGuard | USER_READ |
departments | AuthGuard, permissionGuard | DEPARTMENT_READ |
connections | AuthGuard, permissionGuard | CONNECTION_READ |
settings | AuthGuard, permissionGuard (y AdminGuard en el módulo) | SETTINGS_READ |
Rutas sin protección de sesión: auth/login (solo LoggedInAuthGuard) y auth/reconnect (ninguna).
Spec: permission.guard.spec.ts (4 casos con TestBed.runInInjectionContext: permite, deniega, redirige a /profile/profile-detail, no redirige al permitir). No hay spec del caso catchError.
LoggedInAuthGuard
canActivate(): boolean
Guard inverso: si AuthService.isLoggedIn() es true, ejecuta router.navigateByUrl('/') y devuelve false; en caso contrario devuelve true. Se usa únicamente en features/auth/auth.module.ts sobre la ruta login, para que un usuario con sesión vigente no vea el formulario. / redirige a whaticket/chat (ver Rutas).
Spec: loggedIn.guard.spec.ts (4 casos).
Flujo de decisión
Gotchas
- Destino de redirección inexistente.
permissionGuardenvía a/profile/profile-detail, peroProfileRoutingModulesolo define''ystarred-messages; no existeprofile-detail. Por lectura del árbol de rutas (no ejecutado), esa URL cae en el comodín**(redirige aauth/login) y, como el usuario está logueado,LoggedInAuthGuardlo manda a/y de ahí awhaticket/chat. Un usuario sinTICKET_READpodría entrar en un ciclo de redirecciones, porquewhaticket/chattambién exige permiso. La spec solo comprueba que se llame anavigatecon esa cadena, no que la ruta exista. - Doble guard con efectos laterales. Los guards
canActivatede una misma ruta se subscriben a la vez y el router prioriza el resultado por orden en el array (comportamiento deprioritizedGuardValue; no verificado en ejecución). Por esoAdminGuardopermissionGuardpueden llamar arouter.navigateaunqueAuthGuardya haya devueltofalse. - Solo expiración local.
AuthGuardyLoggedInAuthGuardconfían enexp; el backend sigue siendo la autoridad (401 o 403). - Menú y rutas pueden divergir. El sidebar oculta ítems con
Menu.permissionmediante*appShowWithPermission, pero un enlace directo a la URL sigue pasando porpermissionGuard. Y al revés:dashboard,profileywhaticket/internal-chat(a nivel raíz) no declaran permiso encontent.routes.ts. - Caché de 10 s. Un rol modificado en el servidor puede tardar hasta 10 s en reflejarse; el 403 resultante se muestra como toast (ver Interceptores).