valtech-components 4.0.177 → 4.0.178

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { inject, InjectionToken, makeEnvironmentProviders, APP_INITIALIZER, ErrorHandler, Injectable, DestroyRef, Inject, signal, computed, PLATFORM_ID, isDevMode, Optional, runInInjectionContext, effect, Pipe, TemplateRef, ViewContainerRef, input, Directive, EventEmitter, Component, Input, Output, ChangeDetectionStrategy, HostBinding, HostListener, SecurityContext, NgZone, ViewChild, ElementRef, ChangeDetectorRef, output, ContentChild, model, ViewEncapsulation, untracked, viewChild, Injector, isSignal } from '@angular/core';
3
- import { BehaviorSubject, throwError, Subject, map, distinctUntilChanged, filter as filter$1, take as take$1, firstValueFrom, of, from, EMPTY, Observable, interval, debounceTime, switchMap as switchMap$1, catchError as catchError$1, takeUntil, isObservable, forkJoin, race, timer, shareReplay } from 'rxjs';
4
- import { catchError, switchMap, finalize, filter, take, timeout, map as map$1, tap, distinctUntilChanged as distinctUntilChanged$1, retry, debounceTime as debounceTime$1, takeUntil as takeUntil$1 } from 'rxjs/operators';
3
+ import { BehaviorSubject, throwError, Subject, map, distinctUntilChanged, filter as filter$1, take as take$1, firstValueFrom, of, from, EMPTY, Observable, interval, debounceTime, switchMap as switchMap$1, catchError as catchError$1, takeUntil, isObservable, forkJoin, race, timer, shareReplay as shareReplay$1 } from 'rxjs';
4
+ import { catchError, switchMap, finalize, filter, take, timeout, map as map$1, tap, shareReplay, distinctUntilChanged as distinctUntilChanged$1, retry, debounceTime as debounceTime$1, takeUntil as takeUntil$1 } from 'rxjs/operators';
5
5
  import * as i1$3 from '@angular/common/http';
6
6
  import { provideHttpClient, withInterceptors, HttpClient, HttpErrorResponse } from '@angular/common/http';
7
7
  import * as i1$1 from '@angular/fire/firestore';
@@ -56,7 +56,7 @@ import { BrowserMultiFormatReader } from '@zxing/browser';
56
56
  * Current version of valtech-components.
57
57
  * This is automatically updated during the publish process.
58
58
  */
59
- const VERSION = '4.0.177';
59
+ const VERSION = '4.0.178';
60
60
 
61
61
  // Control de estado de refresco (singleton a nivel de módulo)
62
62
  let isRefreshing = false;
@@ -6587,145 +6587,21 @@ class OAuthService {
6587
6587
  this.clearLocalStorageFallback();
6588
6588
  const nonce = this.generateNonce();
6589
6589
  this.expectedNonce = nonce;
6590
- // popup=true sobrevive el bounce cross-origin (Google/MS borran window.name
6591
- // por privacidad). El backend lo reenvía en el redirect final vía appendParams.
6592
- const redirectUri = `${window.location.origin}/auth/oauth/callback?popup=true`;
6590
+ // Flujo REDIRECT (sin popup). El popup quedaba COLGADO bajo COOP: Google
6591
+ // setea Cross-Origin-Opener-Policy window.opener queda severado (el
6592
+ // postMessage del popup no llega), BroadcastChannel/localStorage no siempre
6593
+ // entregan, y popup.closed queda bloqueado ("COOP policy would block the
6594
+ // window.closed call") → ni siquiera dispara el cancel: la ventana principal
6595
+ // espera para siempre sin redirigir. Navegamos la VENTANA COMPLETA al /start;
6596
+ // al volver, OAuthCallbackComponent corre como ventana principal (redirect_uri
6597
+ // SIN popup=true) y completa la sesión vía completeInMainWindow → navega al
6598
+ // home. Sin popup = sin COOP = sin cuelgue. (El link-flow sigue por popup.)
6599
+ const redirectUri = `${window.location.origin}/auth/oauth/callback`;
6593
6600
  const startUrl = `${this.config?.apiUrl}/v2/auth/oauth/${provider}/start?redirect_uri=${encodeURIComponent(redirectUri)}&client_nonce=${encodeURIComponent(nonce)}`;
6594
- // Abrir popup centrado
6595
- const width = 500;
6596
- const height = 600;
6597
- const left = window.screenX + (window.outerWidth - width) / 2;
6598
- const top = window.screenY + (window.outerHeight - height) / 2;
6599
- const features = `width=${width},height=${height},left=${left},top=${top},popup=yes`;
6600
- this.popup = window.open(startUrl, 'oauth', features);
6601
- if (!this.popup) {
6602
- observer.error({
6603
- code: 'POPUP_BLOCKED',
6604
- message: 'El navegador bloqueó la ventana emergente. Por favor, permite popups para este sitio.',
6605
- });
6606
- return () => { };
6607
- }
6608
- // Escuchar mensajes del popup
6609
- this.messageHandler = (event) => {
6610
- // Validar origen
6611
- if (event.origin !== window.location.origin) {
6612
- return;
6613
- }
6614
- // Validar tipo de mensaje
6615
- const data = event.data;
6616
- if (data?.type !== 'oauth-callback') {
6617
- return;
6618
- }
6619
- // Verificar el nonce del flujo (M-07): si no coincide, no es nuestro
6620
- // callback → ignorar (no consumir el observable).
6621
- if (!this.isNonceValid(data)) {
6622
- return;
6623
- }
6624
- // Limpiar
6625
- this.cleanup();
6626
- // Emitir resultado dentro de NgZone para trigger change detection
6627
- this.ngZone.run(() => {
6628
- if (data.error) {
6629
- observer.error(data.error);
6630
- }
6631
- else if (data.tokens) {
6632
- observer.next(data.tokens);
6633
- observer.complete();
6634
- }
6635
- else {
6636
- observer.error({
6637
- code: 'INVALID_RESPONSE',
6638
- message: 'Respuesta inválida del servidor de autenticación',
6639
- });
6640
- }
6641
- });
6642
- };
6643
- window.addEventListener('message', this.messageHandler);
6644
- // BroadcastChannel: canal adicional immune a COOP (funciona entre misma
6645
- // origin sin importar el browsing context group). Es el canal más fiable
6646
- // cuando COOP sever el link opener→popup al pasar por Google/MS.
6647
- try {
6648
- this.broadcastChannel = new BroadcastChannel('valtech-oauth-callback');
6649
- this.broadcastChannel.onmessage = (event) => {
6650
- const data = event.data;
6651
- if (data?.type !== 'oauth-callback')
6652
- return;
6653
- if (!this.isNonceValid(data))
6654
- return;
6655
- this.clearLocalStorageFallback();
6656
- this.cleanup();
6657
- this.ngZone.run(() => {
6658
- if (data.error) {
6659
- observer.error(data.error);
6660
- }
6661
- else if (data.tokens) {
6662
- observer.next(data.tokens);
6663
- observer.complete();
6664
- }
6665
- else {
6666
- observer.error({
6667
- code: 'INVALID_RESPONSE',
6668
- message: 'Respuesta inválida del servidor de autenticación',
6669
- });
6670
- }
6671
- });
6672
- };
6673
- }
6674
- catch {
6675
- // BroadcastChannel no disponible (contextos muy restringidos) — ignorar
6676
- }
6677
- // Polling de localStorage (COOP workaround - no podemos detectar popup.closed)
6678
- // También verifica si el popup se cerró manualmente
6679
- this.checkClosedInterval = setInterval(() => {
6680
- // Primero verificar localStorage (funciona aunque COOP bloquee todo)
6681
- const storedData = this.checkLocalStorageFallback();
6682
- if (storedData) {
6683
- this.cleanup();
6684
- this.ngZone.run(() => {
6685
- if (storedData.error) {
6686
- observer.error(storedData.error);
6687
- }
6688
- else if (storedData.tokens) {
6689
- // Gate de diagnóstico: la metadata de tokens (keys, presencia y
6690
- // longitud del firebaseToken) no debe filtrarse a la consola en
6691
- // producción (L4 del audit). Solo en dev.
6692
- if (isDevMode()) {
6693
- console.log('[OAuthService] Retrieved tokens from localStorage fallback', {
6694
- keys: Object.keys(storedData.tokens),
6695
- hasFirebaseToken: !!storedData.tokens.firebaseToken,
6696
- firebaseTokenLength: storedData.tokens.firebaseToken?.length ?? 0,
6697
- });
6698
- }
6699
- observer.next(storedData.tokens);
6700
- observer.complete();
6701
- }
6702
- else {
6703
- observer.error({
6704
- code: 'INVALID_RESPONSE',
6705
- message: 'Respuesta inválida del servidor de autenticación',
6706
- });
6707
- }
6708
- });
6709
- return;
6710
- }
6711
- // Intentar verificar si popup se cerró (puede fallar por COOP)
6712
- try {
6713
- if (this.popup?.closed) {
6714
- this.cleanup();
6715
- this.ngZone.run(() => {
6716
- observer.error({
6717
- code: 'POPUP_CLOSED',
6718
- message: 'Se cerró la ventana de autenticación',
6719
- });
6720
- });
6721
- }
6722
- }
6723
- catch {
6724
- // COOP bloquea acceso a popup.closed - ignorar y seguir con polling
6725
- }
6726
- }, 300);
6727
- // Cleanup cuando el observable se destruye
6728
- return () => this.cleanup();
6601
+ window.location.assign(startUrl);
6602
+ // La página se descarga acá; el observable no emite (la finalización ocurre
6603
+ // en el callback, ya como ventana principal). Sin listeners ni polling.
6604
+ return () => { };
6729
6605
  });
6730
6606
  }
6731
6607
  /**
@@ -8352,6 +8228,13 @@ class AuthService {
8352
8228
  this.refreshTimerId = null;
8353
8229
  this.syncSubscription = null;
8354
8230
  this.firestoreRBACUnsubscribe = null;
8231
+ // Single-flight refresh: un solo POST /refresh en vuelo a la vez. Todos los
8232
+ // callers (bootstrap, reestablecer Firebase, interceptor 401, timer proactivo)
8233
+ // comparten el MISMO observable. Sin esto, refreshes concurrentes chocan contra
8234
+ // la rotación de refresh token del backend: el primero rota el token y el
8235
+ // segundo llega con el token ya invalidado → 401 → logout espurio (el usuario
8236
+ // cae a la landing al actualizar). Ver project_pwa-login-freeze.
8237
+ this.inFlightRefresh$ = null;
8355
8238
  // =============================================
8356
8239
  // ESTADO PÚBLICO (Signals readonly)
8357
8240
  // =============================================
@@ -8762,13 +8645,20 @@ class AuthService {
8762
8645
  * que el cliente debe guardar para el próximo refresh.
8763
8646
  */
8764
8647
  refreshAccessToken() {
8648
+ // Single-flight: si ya hay un /refresh en vuelo, devolvemos el MISMO
8649
+ // observable (multicast vía shareReplay). Evita la carrera contra la rotación
8650
+ // de refresh token del backend, que producía 401 espurios → logout al
8651
+ // actualizar. Ver inFlightRefresh$ y project_pwa-login-freeze.
8652
+ if (this.inFlightRefresh$) {
8653
+ return this.inFlightRefresh$;
8654
+ }
8765
8655
  // H-05 same-site-aware: en same-site (prod) el refresh va SOLO por la cookie
8766
8656
  // HttpOnly (body vacío), no se persiste en localStorage → un XSS no lo roba.
8767
8657
  // En cross-site (dev) la cookie de terceros se bloquea (Safari ITP / Chrome
8768
8658
  // 3rd-party phase-out) → se manda en el body + se persiste (fallback).
8769
8659
  // withCredentials adjunta la cookie cuando el browser la permite.
8770
8660
  const body = this.storageService.cookieOnly ? {} : { refreshToken: this.state().refreshToken };
8771
- return this.http
8661
+ this.inFlightRefresh$ = this.http
8772
8662
  .post(`${this.baseUrl}/refresh`, body, { withCredentials: true })
8773
8663
  .pipe(
8774
8664
  // Nunca dejar el refresh colgado: si la red/Lambda no responde a tiempo,
@@ -8798,7 +8688,17 @@ class AuthService {
8798
8688
  // initialize() only clears state (no redirect). Auto-navigating to
8799
8689
  // /login from this method redirects even users on public routes.
8800
8690
  return throwError(() => error);
8801
- }));
8691
+ }),
8692
+ // Liberar el slot al terminar (éxito, error o timeout) para que el
8693
+ // próximo refresh real arranque uno nuevo.
8694
+ finalize(() => {
8695
+ this.inFlightRefresh$ = null;
8696
+ }),
8697
+ // Multicast: los side-effects del tap corren UNA vez y todos los callers
8698
+ // concurrentes reciben la misma respuesta/error. refCount:false → que un
8699
+ // caller se desuscribe temprano NO cancela la rotación de token en vuelo.
8700
+ shareReplay({ bufferSize: 1, refCount: false }));
8701
+ return this.inFlightRefresh$;
8802
8702
  }
8803
8703
  /**
8804
8704
  * Maneja autenticación exitosa desde fuentes externas (OAuth, etc).
@@ -28351,6 +28251,13 @@ class UpdateBannerComponent {
28351
28251
  dismissAction: this.tr('dismissAction'),
28352
28252
  };
28353
28253
  });
28254
+ /**
28255
+ * Feedback visual mientras se aplica el update. El botón pasa a estado
28256
+ * WORKING (spinner) y se bloquean nuevos taps. `applyUpdate()` siempre
28257
+ * recarga la página al terminar (try/finally), así que NO reseteamos este
28258
+ * flag: queda en true hasta que el reload reemplaza la app.
28259
+ */
28260
+ this.applying = signal(false);
28354
28261
  if (!this.i18n.hasNamespace(UPDATE_BANNER_I18N_NAMESPACE)) {
28355
28262
  this.i18n.registerContent(UPDATE_BANNER_I18N_NAMESPACE, {
28356
28263
  es: UPDATE_BANNER_DEFAULT_CONTENT.es,
@@ -28360,6 +28267,11 @@ class UpdateBannerComponent {
28360
28267
  }
28361
28268
  /** Aplica la actualización (activa el SW si existe + recarga). */
28362
28269
  onUpdate() {
28270
+ // Anti multi-tap: el botón WORKING no queda `disabled` a nivel DOM, así que
28271
+ // sin este guard un usuario impaciente dispararía applyUpdate() N veces.
28272
+ if (this.applying())
28273
+ return;
28274
+ this.applying.set(true);
28363
28275
  void this.version.applyUpdate();
28364
28276
  }
28365
28277
  /** Descarta el banner opcional. No-op en modo obligatorio. */
@@ -28429,7 +28341,7 @@ class UpdateBannerComponent {
28429
28341
  color: 'primary',
28430
28342
  fill: 'solid',
28431
28343
  size: 'small',
28432
- state: 'ENABLED',
28344
+ state: applying() ? 'WORKING' : 'ENABLED',
28433
28345
  type: 'button',
28434
28346
  }"
28435
28347
  (onClick)="onUpdate()"
@@ -28441,7 +28353,7 @@ class UpdateBannerComponent {
28441
28353
  color: 'dark',
28442
28354
  fill: 'clear',
28443
28355
  size: 'small',
28444
- state: 'ENABLED',
28356
+ state: applying() ? 'DISABLED' : 'ENABLED',
28445
28357
  type: 'button',
28446
28358
  }"
28447
28359
  (onClick)="onDismiss()"
@@ -28504,7 +28416,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
28504
28416
  color: 'primary',
28505
28417
  fill: 'solid',
28506
28418
  size: 'small',
28507
- state: 'ENABLED',
28419
+ state: applying() ? 'WORKING' : 'ENABLED',
28508
28420
  type: 'button',
28509
28421
  }"
28510
28422
  (onClick)="onUpdate()"
@@ -28516,7 +28428,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
28516
28428
  color: 'dark',
28517
28429
  fill: 'clear',
28518
28430
  size: 'small',
28519
- state: 'ENABLED',
28431
+ state: applying() ? 'DISABLED' : 'ENABLED',
28520
28432
  type: 'button',
28521
28433
  }"
28522
28434
  (onClick)="onDismiss()"
@@ -35347,7 +35259,7 @@ class HtmlViewerModalComponent {
35347
35259
  <ion-content>
35348
35260
  <iframe [srcdoc]="safeHtml" style="width: 100%; height: 100%; border: none;" title="Visor de documento"></iframe>
35349
35261
  </ion-content>
35350
- `, isInline: true, styles: [":host{display:flex;flex-direction:column}ion-content{--padding-bottom: 0;--padding-top: 0}iframe{display:block}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }] }); }
35262
+ `, isInline: true, styles: [":host{display:flex;flex-direction:column}ion-content{--padding-top: 12px;--padding-bottom: 12px;--padding-start: 12px;--padding-end: 12px;--background: var(--ion-color-light, #f4f5f8)}iframe{display:block;border-radius:8px;background:#fff}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }] }); }
35351
35263
  }
35352
35264
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: HtmlViewerModalComponent, decorators: [{
35353
35265
  type: Component,
@@ -35370,7 +35282,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
35370
35282
  <ion-content>
35371
35283
  <iframe [srcdoc]="safeHtml" style="width: 100%; height: 100%; border: none;" title="Visor de documento"></iframe>
35372
35284
  </ion-content>
35373
- `, styles: [":host{display:flex;flex-direction:column}ion-content{--padding-bottom: 0;--padding-top: 0}iframe{display:block}\n"] }]
35285
+ `, styles: [":host{display:flex;flex-direction:column}ion-content{--padding-top: 12px;--padding-bottom: 12px;--padding-start: 12px;--padding-end: 12px;--background: var(--ion-color-light, #f4f5f8)}iframe{display:block;border-radius:8px;background:#fff}\n"] }]
35374
35286
  }], propDecorators: { html: [{
35375
35287
  type: Input
35376
35288
  }], title: [{
@@ -61903,7 +61815,7 @@ class LegalContentService {
61903
61815
  const stream = fallback && fallback !== locale
61904
61816
  ? primary.pipe(catchError$1(() => this.loadOne(slug, fallback, options.basePath)))
61905
61817
  : primary;
61906
- const shared = stream.pipe(shareReplay({ bufferSize: 1, refCount: false }));
61818
+ const shared = stream.pipe(shareReplay$1({ bufferSize: 1, refCount: false }));
61907
61819
  this.cache.set(key, shared);
61908
61820
  return shared;
61909
61821
  }