Interceptores HTTP
Tres interceptores de clase (HttpInterceptor) en src/app/core/interceptors/. Cada archivo exporta además un array *_INTERCEPTOR_PROVIDERS con { provide: HTTP_INTERCEPTORS, useClass: ..., multi: true }, que AppModule agrega a providers. No se usa provideHttpClient(withInterceptors(...)): la app importa HttpClientModule.
Resumen y tabla de errores en Auth, interceptores y RBAC; esta página detalla el contrato exacto. Guards relacionados: Guards.
Orden de la cadena
AppModule registra AuthTokenInterceptor, ErrorInterceptor y DeviceTokenInterceptor, en ese orden. La petición los atraviesa en ese orden; la respuesta (y los errores) vuelve en el inverso.
Consecuencias:
ErrorInterceptorve la petición ya con los headers deAuthTokenInterceptor(por eso puede leerskip) pero antes de los deDeviceTokenInterceptor.- Los errores llegan a
ErrorInterceptorsin queAuthTokenInterceptorniDeviceTokenInterceptorlos toquen: ninguno de los dos usacatchError. - Además de estos tres,
LoadingBarHttpClientModule(barra de progreso) registra su propio interceptor al importarse enAppModule.
AuthTokenInterceptor
Archivo: auth-token.interceptor.ts. providedIn: 'root'. Dependencias: StorageService, Router, IframeDetectionService.
Headers que añade:
| Header | Valor | Cuándo |
|---|---|---|
Timezone-Offset | new Date().getTimezoneOffset() (minutos; positivo al oeste de UTC) | siempre |
Timezone-Name | Intl.DateTimeFormat().resolvedOptions().timeZone (p. ej. America/Guayaquil) | siempre |
x-api-key (TOKEN_HEADER_KEY) | session.token de USER_SESSION | solo si hay token y la petición no lleva skip |
Lógica:
- Si la petición tiene el header
skip(valor truthy), clona con solo los headers de zona horaria y continúa. Sin token. - Si no, lee
USER_SESSIONdesecureStorage(cifrado, ver Servicios) y extraetoken. - Con token:
req.headers.set('x-api-key', token)más zona horaria. - Sin token: si no es iframe,
router.navigate(['/auth/login']); en iframe no redirige (se espera el handshake, ver Frame bridge). En ambos casos la petición sigue su curso solo con los headers de zona horaria.
Quién usa skip: AuthService.login y AuthService.frameLogin (new HttpHeaders({ skip: 'skip' }), core/services/api/auth.service.ts).
Gotchas:
- El header
skipno se elimina: viaja al backend junto con la petición de login. - Sin token se navega a login pero se envía igual la petición; el backend responderá 401 y
ErrorInterceptoraplicará su propio logout/redirección (doble navegación). - En el archivo,
let authReq = req;yconst authReqdentro delifson variables distintas (la interna sombrea a la externa); es inocuo pero confuso. - No hay spec para este interceptor.
ErrorInterceptor
Archivo: http-error.interceptor.ts. @Injectable() sin providedIn (solo existe por el provider de AppModule). Dependencias: ToastrNotificationService, TranslateService, Store, Router, IframeDetectionService, FrameBridgeService.
Envuelve next.handle(req) con catchError. Las ramas se evalúan en este orden (la primera que coincide gana):
| # | Condición | Efecto | Error que ve el llamador |
|---|---|---|---|
| 1 | Petición con skip y status 401 | nada | el HttpErrorResponse original |
| 2 | Status 401, en iframe | FrameBridgeService.emit('CRM_AUTH_EXPIRED') y navigate(['/auth/reconnect']) | new Error('') (mensaje vacío) |
| 2 | Status 401, fuera de iframe | dispatch(UserActions.userLogout()) y navigate(['/auth/login']) | new Error('') |
| 3 | Status 403 | toast error con título words.forbiddenActionTitle y mensaje words.forbiddenAction; sin logout ni redirección | new Error(<mensaje traducido de words.forbiddenAction>) |
| 4 | error.error?.data?.code presente (cualquier otro status) | sin toast | el HttpErrorResponse original |
| 5 | Cualquier otro (400, 404, 409 sin code, 500, red caída...) | toast error con título literal Error y mensaje error.error.message (o words.unknownError) | new Error(<mensaje traducido>) |
Notas por rama:
- Rama 1: un login con credenciales erróneas devuelve 401; se deja intacto para que
AuthEffectslea el mensaje real. Sin esta rama el usuario vería un mensaje vacío. - Rama 2: el
Error('')vacío significa que loscatchErrorde los efectos no deben mostrar el mensaje; ya se resolvió con la redirección. - Rama 3: se aplica aunque el cuerpo traiga un
data.code, porque se evalúa antes que la rama 4. El spec cubre el caso de caché de permisos obsoleta: el guard dejó pasar, pero el backend (autoridad) respondió 403. - Rama 4: introducida por
sdd/ticket-lock(decisión D5). Ejemplo real: un 409 condata.code = 'TICKET_LOCKED'llega intacto al efecto o componente, que ramifica por código sin ver un toast genérico. También sirve paraMETA_NOT_CONFIGUREDen el OAuth de Instagram. - Rama 5:
getMessageTestacepta un string (lo pasa portranslate.instant) o un objeto multilenguaje (elige el idioma deLanguageService.code). Un error de red (status 0) no traeerror.error.message, por lo que muestrawords.unknownError. El títuloErrorno está traducido.
Specs: http-error.interceptor.spec.ts (403 con toast distinto, sin logout ni navegación, 401 sigue haciendo logout, 500 genérico, y tres casos de paso de errores tipados). No hay spec de las ramas 1 y 2 en iframe.
DeviceTokenInterceptor
Archivo: device-token.interceptor.ts. providedIn: 'root'. Dependencias: DeviceTokenService, LanguageService.
| Header | Valor | Condición |
|---|---|---|
X-Device-Token | DeviceTokenService.getOrCreateDeviceToken() | solo si la petición no lo trae ya |
X-Lang | LanguageService.code.value (es por defecto, en, ...) | solo si la petición no lo trae ya |
Detalles:
- Si ambos headers ya existen no se clona la petición.
- El idioma sale del store del tenant (
selectLanguage), no denavigator.language; el spec lo comprueba (ignores the browser language). DeviceTokenServiceguarda un UUID v4 enlocalStoragebajobackoffice_device_token. SilocalStorageno está disponible (modo privado) el UUID no persiste. Genera concrypto.randomUUID, con respaldo agetRandomValuesy, en último caso, con un valorfallback-<timestamp>-<random>.rotateDeviceTokenexiste pero no se invoca desde ningún sitio fuera de pruebas.- Se aplica también a las peticiones
skip(login), por eso el backend recibe el token de dispositivo desde el primer login.
Specs: device-token.interceptor.spec.ts (3 casos: envía X-Lang y X-Device-Token, ignora el idioma del navegador, no pisa un X-Lang ya presente).
Resumen de headers por petición
| Header | Origen | Petición normal | Petición skip |
|---|---|---|---|
x-api-key | AuthToken | si hay token | no |
Timezone-Offset, Timezone-Name | AuthToken | si | si |
skip | llamador | no | si (no se elimina) |
X-Device-Token, X-Lang | DeviceToken | si (salvo que ya existan) | si |