valtech-components 2.0.452 → 2.0.453
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.
- package/esm2022/lib/services/auth/auth-state.service.mjs +173 -0
- package/esm2022/lib/services/auth/auth.service.mjs +454 -0
- package/esm2022/lib/services/auth/config.mjs +76 -0
- package/esm2022/lib/services/auth/guards.mjs +194 -0
- package/esm2022/lib/services/auth/index.mjs +70 -0
- package/esm2022/lib/services/auth/interceptor.mjs +98 -0
- package/esm2022/lib/services/auth/storage.service.mjs +141 -0
- package/esm2022/lib/services/auth/sync.service.mjs +149 -0
- package/esm2022/lib/services/auth/token.service.mjs +113 -0
- package/esm2022/lib/services/auth/types.mjs +29 -0
- package/esm2022/lib/services/firebase/config.mjs +108 -0
- package/esm2022/lib/services/firebase/firebase.service.mjs +288 -0
- package/esm2022/lib/services/firebase/firestore-collection.mjs +254 -0
- package/esm2022/lib/services/firebase/firestore.service.mjs +509 -0
- package/esm2022/lib/services/firebase/index.mjs +49 -0
- package/esm2022/lib/services/firebase/messaging.service.mjs +512 -0
- package/esm2022/lib/services/firebase/shared-config.mjs +138 -0
- package/esm2022/lib/services/firebase/storage.service.mjs +422 -0
- package/esm2022/lib/services/firebase/types.mjs +8 -0
- package/esm2022/lib/services/firebase/utils/path-builder.mjs +195 -0
- package/esm2022/lib/services/firebase/utils/query-builder.mjs +302 -0
- package/esm2022/public-api.mjs +3 -5
- package/fesm2022/valtech-components.mjs +4195 -4
- package/fesm2022/valtech-components.mjs.map +1 -1
- package/lib/services/auth/auth-state.service.d.ts +85 -0
- package/lib/services/auth/auth.service.d.ts +146 -0
- package/lib/services/auth/config.d.ts +38 -0
- package/lib/services/auth/guards.d.ts +123 -0
- package/lib/services/auth/index.d.ts +63 -0
- package/lib/services/auth/interceptor.d.ts +22 -0
- package/lib/services/auth/storage.service.d.ts +48 -0
- package/lib/services/auth/sync.service.d.ts +49 -0
- package/lib/services/auth/token.service.d.ts +51 -0
- package/lib/services/auth/types.d.ts +315 -0
- package/lib/services/firebase/config.d.ts +49 -0
- package/lib/services/firebase/firebase.service.d.ts +140 -0
- package/lib/services/firebase/firestore-collection.d.ts +175 -0
- package/lib/services/firebase/firestore.service.d.ts +304 -0
- package/lib/services/firebase/index.d.ts +39 -0
- package/lib/services/firebase/messaging.service.d.ts +263 -0
- package/lib/services/firebase/shared-config.d.ts +126 -0
- package/lib/services/firebase/storage.service.d.ts +206 -0
- package/lib/services/firebase/types.d.ts +281 -0
- package/lib/services/firebase/utils/path-builder.d.ts +132 -0
- package/lib/services/firebase/utils/query-builder.d.ts +210 -0
- package/package.json +1 -1
- package/public-api.d.ts +2 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { EventEmitter, Component, Input, Output, Injectable, HostListener, inject, Pipe, ChangeDetectionStrategy, ViewChild, ChangeDetectorRef, ElementRef, signal, computed } from '@angular/core';
|
|
2
|
+
import { EventEmitter, Component, Input, Output, Injectable, HostListener, inject, Pipe, ChangeDetectionStrategy, ViewChild, ChangeDetectorRef, ElementRef, signal, computed, InjectionToken, makeEnvironmentProviders, Inject, PLATFORM_ID, Optional, APP_INITIALIZER } from '@angular/core';
|
|
3
3
|
import * as i2$1 from '@ionic/angular/standalone';
|
|
4
4
|
import { IonAvatar, IonCard, IonIcon, IonButton, IonSpinner, IonText, IonModal, IonHeader, IonToolbar, IonContent, IonButtons, IonTitle, IonProgressBar, IonSkeletonText, IonFab, IonFabButton, IonFabList, IonLabel, IonCardContent, IonCardHeader, IonCardTitle, IonCardSubtitle, IonCheckbox, IonTextarea, IonDatetime, IonDatetimeButton, IonInput, IonSelect, IonSelectOption, IonRadioGroup, IonRadio, IonRange, IonSearchbar, IonSegment, IonSegmentButton, IonToggle, IonAccordion, IonAccordionGroup, IonItem, IonTabBar, IonTabButton, IonBadge, IonBreadcrumb, IonBreadcrumbs, IonChip, IonPopover, IonList, IonNote, ToastController as ToastController$1, IonCol, IonRow, IonMenuButton, IonFooter, IonListHeader, IonInfiniteScroll, IonInfiniteScrollContent, IonGrid, MenuController, IonMenu, IonMenuToggle, AlertController } from '@ionic/angular/standalone';
|
|
5
5
|
import * as i1 from '@angular/common';
|
|
6
|
-
import { CommonModule, NgStyle, NgFor, NgClass } from '@angular/common';
|
|
6
|
+
import { CommonModule, NgStyle, NgFor, NgClass, isPlatformBrowser } from '@angular/common';
|
|
7
7
|
import { addIcons } from 'ionicons';
|
|
8
8
|
import { addOutline, addCircleOutline, alertOutline, alertCircleOutline, arrowBackOutline, arrowForwardOutline, arrowDownOutline, checkmarkCircleOutline, ellipsisHorizontalOutline, notificationsOutline, openOutline, closeOutline, chatbubblesOutline, shareOutline, heart, heartOutline, homeOutline, eyeOffOutline, eyeOutline, scanOutline, chevronDownOutline, chevronForwardOutline, checkmarkOutline, clipboardOutline, copyOutline, filterOutline, locationOutline, calendarOutline, businessOutline, logoTwitter, logoInstagram, logoLinkedin, logoYoutube, logoTiktok, logoFacebook, logoGoogle, createOutline, trashOutline, playOutline, refreshOutline, documentTextOutline, lockClosedOutline, informationCircleOutline, logoNpm, removeOutline, add, close, share, create, trash, star, camera, mic, send, downloadOutline, chevronDown, language, list, grid, apps, menu, settings, home, search, person, helpCircle, informationCircle, documentText, notifications, mail, calendar, folder, chevronForward, ellipsisHorizontal, chevronBack, playBack, playForward, checkmark, ellipse, starOutline, starHalf, heartHalf, checkmarkCircle, timeOutline, flag, trendingUp, trendingDown, remove, analytics, people, cash, cart, eye, chatbubbleOutline, thumbsUpOutline, thumbsUp, happyOutline, happy, sadOutline, sad, chevronUp, pin, pencil, callOutline, shuffleOutline, logoWhatsapp, paperPlaneOutline, mailOutline, trophyOutline, ticketOutline, giftOutline, personOutline, ellipsisVertical, closeCircle, chevronBackOutline, sendOutline, chatbubbleEllipsesOutline, swapVerticalOutline, chevronUpOutline, documentOutline } from 'ionicons/icons';
|
|
9
9
|
import * as i1$1 from '@angular/router';
|
|
@@ -13,7 +13,7 @@ import * as i1$2 from '@angular/platform-browser';
|
|
|
13
13
|
import QRCodeStyling from 'qr-code-styling';
|
|
14
14
|
import * as i1$3 from '@angular/forms';
|
|
15
15
|
import { ReactiveFormsModule, FormsModule, FormControl, Validators } from '@angular/forms';
|
|
16
|
-
import { BehaviorSubject, filter } from 'rxjs';
|
|
16
|
+
import { BehaviorSubject, filter, map, distinctUntilChanged, Subject, firstValueFrom, throwError, of } from 'rxjs';
|
|
17
17
|
import * as i1$4 from 'ng-otp-input';
|
|
18
18
|
import { NgOtpInputComponent, NgOtpInputModule } from 'ng-otp-input';
|
|
19
19
|
import * as i2 from '@ionic/angular';
|
|
@@ -27,6 +27,18 @@ import 'prismjs/components/prism-typescript';
|
|
|
27
27
|
import 'prismjs/components/prism-bash';
|
|
28
28
|
import Swiper from 'swiper';
|
|
29
29
|
import { Navigation, Pagination, EffectFade, EffectCube, EffectCoverflow, EffectFlip, Autoplay } from 'swiper/modules';
|
|
30
|
+
import { provideFirebaseApp, initializeApp } from '@angular/fire/app';
|
|
31
|
+
import * as i1$5 from '@angular/fire/auth';
|
|
32
|
+
import { provideAuth, getAuth, connectAuthEmulator, authState, signInWithCustomToken, signOut } from '@angular/fire/auth';
|
|
33
|
+
import * as i1$6 from '@angular/fire/firestore';
|
|
34
|
+
import { provideFirestore, getFirestore, connectFirestoreEmulator, enableIndexedDbPersistence, doc, getDoc, collection, query as query$1, getDocs, limit, docData, collectionData, serverTimestamp, addDoc, setDoc, updateDoc, deleteDoc, writeBatch, arrayUnion, arrayRemove, increment, where, orderBy, startAfter, startAt, endBefore, endAt, Timestamp } from '@angular/fire/firestore';
|
|
35
|
+
import * as i1$8 from '@angular/fire/messaging';
|
|
36
|
+
import { provideMessaging, getMessaging, getToken, deleteToken, onMessage } from '@angular/fire/messaging';
|
|
37
|
+
import * as i1$7 from '@angular/fire/storage';
|
|
38
|
+
import { provideStorage, getStorage, connectStorageEmulator, ref, uploadBytesResumable, getDownloadURL, getMetadata, deleteObject, listAll } from '@angular/fire/storage';
|
|
39
|
+
import * as i1$9 from '@angular/common/http';
|
|
40
|
+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
41
|
+
import { tap, catchError, switchMap, finalize, filter as filter$1, take } from 'rxjs/operators';
|
|
30
42
|
|
|
31
43
|
/**
|
|
32
44
|
* val-avatar
|
|
@@ -21128,6 +21140,4185 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
21128
21140
|
}]
|
|
21129
21141
|
}], ctorParameters: () => [{ type: i2$1.ModalController }] });
|
|
21130
21142
|
|
|
21143
|
+
/**
|
|
21144
|
+
* Firebase Types
|
|
21145
|
+
*
|
|
21146
|
+
* Tipos e interfaces para la integración de Firebase en valtech-components.
|
|
21147
|
+
* Todos los modelos de Firestore deben extender FirestoreDocument.
|
|
21148
|
+
*/
|
|
21149
|
+
|
|
21150
|
+
/**
|
|
21151
|
+
* Firebase Configuration
|
|
21152
|
+
*
|
|
21153
|
+
* Configuración e inicialización de Firebase para aplicaciones Angular.
|
|
21154
|
+
* Usa provideValtechFirebase() en el bootstrap de tu aplicación.
|
|
21155
|
+
*/
|
|
21156
|
+
/**
|
|
21157
|
+
* Token de inyección para la configuración de Firebase.
|
|
21158
|
+
* Usado internamente por los servicios de Firebase.
|
|
21159
|
+
*/
|
|
21160
|
+
const VALTECH_FIREBASE_CONFIG = new InjectionToken('ValtechFirebaseConfig');
|
|
21161
|
+
/**
|
|
21162
|
+
* Provee Firebase a la aplicación Angular.
|
|
21163
|
+
*
|
|
21164
|
+
* @param config - Configuración de Firebase
|
|
21165
|
+
* @returns EnvironmentProviders para usar en bootstrapApplication
|
|
21166
|
+
*
|
|
21167
|
+
* @example
|
|
21168
|
+
* ```typescript
|
|
21169
|
+
* // main.ts
|
|
21170
|
+
* import { bootstrapApplication } from '@angular/platform-browser';
|
|
21171
|
+
* import { provideValtechFirebase } from 'valtech-components';
|
|
21172
|
+
* import { environment } from './environments/environment';
|
|
21173
|
+
*
|
|
21174
|
+
* bootstrapApplication(AppComponent, {
|
|
21175
|
+
* providers: [
|
|
21176
|
+
* provideValtechFirebase({
|
|
21177
|
+
* firebase: environment.firebase,
|
|
21178
|
+
* persistence: true,
|
|
21179
|
+
* emulator: environment.useEmulators ? {
|
|
21180
|
+
* firestore: { host: 'localhost', port: 8080 },
|
|
21181
|
+
* auth: { host: 'localhost', port: 9099 },
|
|
21182
|
+
* storage: { host: 'localhost', port: 9199 },
|
|
21183
|
+
* } : undefined,
|
|
21184
|
+
* }),
|
|
21185
|
+
* ],
|
|
21186
|
+
* });
|
|
21187
|
+
* ```
|
|
21188
|
+
*/
|
|
21189
|
+
function provideValtechFirebase(config) {
|
|
21190
|
+
// Construir array de providers base
|
|
21191
|
+
const providers = [
|
|
21192
|
+
// Guardar configuración para uso en servicios
|
|
21193
|
+
{ provide: VALTECH_FIREBASE_CONFIG, useValue: config },
|
|
21194
|
+
// Inicializar Firebase App
|
|
21195
|
+
provideFirebaseApp(() => initializeApp(config.firebase)),
|
|
21196
|
+
// Firestore con soporte para emuladores y persistencia
|
|
21197
|
+
provideFirestore(() => {
|
|
21198
|
+
const firestore = getFirestore();
|
|
21199
|
+
// Conectar a emulador si está configurado
|
|
21200
|
+
if (config.emulator?.firestore) {
|
|
21201
|
+
connectFirestoreEmulator(firestore, config.emulator.firestore.host, config.emulator.firestore.port);
|
|
21202
|
+
}
|
|
21203
|
+
// Habilitar persistencia offline si está configurada
|
|
21204
|
+
if (config.persistence) {
|
|
21205
|
+
enableIndexedDbPersistence(firestore).catch((err) => {
|
|
21206
|
+
if (err.code === 'failed-precondition') {
|
|
21207
|
+
console.warn('[ValtechFirebase] Persistencia no disponible: múltiples pestañas abiertas');
|
|
21208
|
+
}
|
|
21209
|
+
else if (err.code === 'unimplemented') {
|
|
21210
|
+
console.warn('[ValtechFirebase] Persistencia no soportada en este navegador');
|
|
21211
|
+
}
|
|
21212
|
+
});
|
|
21213
|
+
}
|
|
21214
|
+
return firestore;
|
|
21215
|
+
}),
|
|
21216
|
+
// Auth con soporte para emulador
|
|
21217
|
+
provideAuth(() => {
|
|
21218
|
+
const auth = getAuth();
|
|
21219
|
+
// Conectar a emulador si está configurado
|
|
21220
|
+
if (config.emulator?.auth) {
|
|
21221
|
+
connectAuthEmulator(auth, `http://${config.emulator.auth.host}:${config.emulator.auth.port}`, { disableWarnings: true });
|
|
21222
|
+
}
|
|
21223
|
+
return auth;
|
|
21224
|
+
}),
|
|
21225
|
+
// Storage con soporte para emulador
|
|
21226
|
+
provideStorage(() => {
|
|
21227
|
+
const storage = getStorage();
|
|
21228
|
+
// Conectar a emulador si está configurado
|
|
21229
|
+
if (config.emulator?.storage) {
|
|
21230
|
+
connectStorageEmulator(storage, config.emulator.storage.host, config.emulator.storage.port);
|
|
21231
|
+
}
|
|
21232
|
+
return storage;
|
|
21233
|
+
}),
|
|
21234
|
+
];
|
|
21235
|
+
// Messaging (FCM) - solo si está explícitamente habilitado
|
|
21236
|
+
// Requiere Service Worker configurado, puede congelar la app si no está disponible
|
|
21237
|
+
if (config.enableMessaging) {
|
|
21238
|
+
providers.push(provideMessaging(() => getMessaging()));
|
|
21239
|
+
}
|
|
21240
|
+
return makeEnvironmentProviders(providers);
|
|
21241
|
+
}
|
|
21242
|
+
/**
|
|
21243
|
+
* Verifica si los emuladores están configurados.
|
|
21244
|
+
*
|
|
21245
|
+
* @param config - Configuración de Firebase
|
|
21246
|
+
* @returns true si hay al menos un emulador configurado
|
|
21247
|
+
*/
|
|
21248
|
+
function hasEmulators(config) {
|
|
21249
|
+
return !!(config.emulator?.firestore || config.emulator?.auth || config.emulator?.storage);
|
|
21250
|
+
}
|
|
21251
|
+
|
|
21252
|
+
/**
|
|
21253
|
+
* Firebase Shared Configuration
|
|
21254
|
+
*
|
|
21255
|
+
* Configuración base de Firebase compartida entre todas las apps del monorepo.
|
|
21256
|
+
* Los secrets (apiKey, appId) se inyectan en build time via environment.
|
|
21257
|
+
*/
|
|
21258
|
+
const APP_IDS = {
|
|
21259
|
+
DEMO: 'demo',
|
|
21260
|
+
SHOWCASE: 'showcase',
|
|
21261
|
+
ADMIN_PORTAL: 'admin-portal',
|
|
21262
|
+
APP: 'app',
|
|
21263
|
+
};
|
|
21264
|
+
// ============================================================================
|
|
21265
|
+
// FIREBASE PROJECT IDS
|
|
21266
|
+
// ============================================================================
|
|
21267
|
+
/**
|
|
21268
|
+
* IDs de los proyectos Firebase por ambiente.
|
|
21269
|
+
* Deben coincidir con los proyectos creados en Firebase Console.
|
|
21270
|
+
*/
|
|
21271
|
+
const FIREBASE_PROJECTS = {
|
|
21272
|
+
development: 'myvaltech-dev',
|
|
21273
|
+
production: 'myvaltech-prod',
|
|
21274
|
+
};
|
|
21275
|
+
// ============================================================================
|
|
21276
|
+
// EMULATOR CONFIGURATION (shared across all apps)
|
|
21277
|
+
// ============================================================================
|
|
21278
|
+
/**
|
|
21279
|
+
* Configuración de emuladores compartida.
|
|
21280
|
+
* Todos los puertos deben coincidir con frontend/firebase/firebase.json
|
|
21281
|
+
*/
|
|
21282
|
+
const SHARED_EMULATOR_CONFIG = {
|
|
21283
|
+
firestore: { host: 'localhost', port: 9080 },
|
|
21284
|
+
storage: { host: 'localhost', port: 9199 },
|
|
21285
|
+
auth: { host: 'localhost', port: 9099 },
|
|
21286
|
+
};
|
|
21287
|
+
// ============================================================================
|
|
21288
|
+
// STORAGE PATH BUILDERS
|
|
21289
|
+
// ============================================================================
|
|
21290
|
+
/**
|
|
21291
|
+
* Genera paths de Storage con namespace por app.
|
|
21292
|
+
*
|
|
21293
|
+
* @example
|
|
21294
|
+
* // Path específico de la app
|
|
21295
|
+
* storagePaths.forApp('showcase', 'uploads', 'image.jpg')
|
|
21296
|
+
* // => 'showcase/uploads/image.jpg'
|
|
21297
|
+
*
|
|
21298
|
+
* // Path compartido
|
|
21299
|
+
* storagePaths.shared.profilePhoto('user123', 'avatar.jpg')
|
|
21300
|
+
* // => 'profile-photos/user123/avatar.jpg'
|
|
21301
|
+
*/
|
|
21302
|
+
const storagePaths = {
|
|
21303
|
+
/** Carpeta específica de la app: {appId}/{...paths} */
|
|
21304
|
+
forApp: (appId, ...paths) => [appId, ...paths].join('/'),
|
|
21305
|
+
/** Carpetas compartidas (sin namespace) */
|
|
21306
|
+
shared: {
|
|
21307
|
+
/** Foto de perfil de usuario */
|
|
21308
|
+
profilePhoto: (userId, fileName) => `profile-photos/${userId}/${fileName}`,
|
|
21309
|
+
/** Archivos públicos accesibles sin autenticación */
|
|
21310
|
+
public: (...paths) => `public/${paths.join('/')}`,
|
|
21311
|
+
},
|
|
21312
|
+
/** Carpetas de desarrollo (acceso libre en emuladores) */
|
|
21313
|
+
demo: (...paths) => `demo/${paths.join('/')}`,
|
|
21314
|
+
};
|
|
21315
|
+
// ============================================================================
|
|
21316
|
+
// FIRESTORE COLLECTION BUILDERS
|
|
21317
|
+
// ============================================================================
|
|
21318
|
+
/**
|
|
21319
|
+
* Genera paths de colecciones con namespace por app.
|
|
21320
|
+
*
|
|
21321
|
+
* IMPORTANTE: La estructura es /apps/{appId}/{collection}/{docId}
|
|
21322
|
+
* Firestore requiere número impar de segmentos para paths de colección.
|
|
21323
|
+
*
|
|
21324
|
+
* @example
|
|
21325
|
+
* // Colección específica de la app
|
|
21326
|
+
* collections.forApp('showcase', 'items')
|
|
21327
|
+
* // => 'apps/showcase/items'
|
|
21328
|
+
*
|
|
21329
|
+
* // Colección de desarrollo
|
|
21330
|
+
* collections.forApp('demo', 'items')
|
|
21331
|
+
* // => 'apps/demo/items'
|
|
21332
|
+
*
|
|
21333
|
+
* // Colección compartida
|
|
21334
|
+
* collections.shared.users
|
|
21335
|
+
* // => 'users'
|
|
21336
|
+
*/
|
|
21337
|
+
const collections = {
|
|
21338
|
+
/** Colección específica de la app: apps/{appId}/{collection} */
|
|
21339
|
+
forApp: (appId, collectionName) => `apps/${appId}/${collectionName}`,
|
|
21340
|
+
/** Colecciones compartidas (sin namespace, nivel raíz) */
|
|
21341
|
+
shared: {
|
|
21342
|
+
/** Usuarios del sistema */
|
|
21343
|
+
users: 'users',
|
|
21344
|
+
/** Perfiles públicos */
|
|
21345
|
+
profiles: 'profiles',
|
|
21346
|
+
/** Notificaciones de usuarios */
|
|
21347
|
+
notifications: 'notifications',
|
|
21348
|
+
},
|
|
21349
|
+
};
|
|
21350
|
+
/**
|
|
21351
|
+
* Crea la configuración completa de Firebase desde variables de entorno.
|
|
21352
|
+
* Usa esto en el environment.ts de cada app.
|
|
21353
|
+
*
|
|
21354
|
+
* @example
|
|
21355
|
+
* // environment.ts
|
|
21356
|
+
* export const environment = {
|
|
21357
|
+
* firebase: createFirebaseConfig(
|
|
21358
|
+
* {
|
|
21359
|
+
* apiKey: 'AIza...',
|
|
21360
|
+
* authDomain: 'myvaltech-dev.firebaseapp.com',
|
|
21361
|
+
* projectId: 'myvaltech-dev',
|
|
21362
|
+
* storageBucket: 'myvaltech-dev.appspot.com',
|
|
21363
|
+
* messagingSenderId: '123456789',
|
|
21364
|
+
* appId: '1:123456789:web:abc123',
|
|
21365
|
+
* },
|
|
21366
|
+
* { useEmulators: true, persistence: true }
|
|
21367
|
+
* ),
|
|
21368
|
+
* };
|
|
21369
|
+
*/
|
|
21370
|
+
function createFirebaseConfig(envConfig, options = {}) {
|
|
21371
|
+
const { useEmulators = false, persistence = true, enableMessaging = false, messagingVapidKey } = options;
|
|
21372
|
+
return {
|
|
21373
|
+
firebase: envConfig,
|
|
21374
|
+
persistence,
|
|
21375
|
+
enableMessaging,
|
|
21376
|
+
messagingVapidKey,
|
|
21377
|
+
emulator: useEmulators ? SHARED_EMULATOR_CONFIG : undefined,
|
|
21378
|
+
};
|
|
21379
|
+
}
|
|
21380
|
+
// ============================================================================
|
|
21381
|
+
// UTILITY: Check if running in emulator mode
|
|
21382
|
+
// ============================================================================
|
|
21383
|
+
/**
|
|
21384
|
+
* Verifica si la configuración tiene emuladores habilitados
|
|
21385
|
+
*/
|
|
21386
|
+
function isEmulatorMode(config) {
|
|
21387
|
+
return config.emulator !== undefined;
|
|
21388
|
+
}
|
|
21389
|
+
|
|
21390
|
+
/**
|
|
21391
|
+
* Firebase Service
|
|
21392
|
+
*
|
|
21393
|
+
* Servicio principal para la autenticación con Firebase usando Custom Tokens.
|
|
21394
|
+
* Permite que usuarios autenticados con tu backend (Cognito, etc.) accedan
|
|
21395
|
+
* a servicios de Firebase (Firestore, Storage, FCM) de manera segura.
|
|
21396
|
+
*/
|
|
21397
|
+
/**
|
|
21398
|
+
* Servicio de autenticación de Firebase.
|
|
21399
|
+
*
|
|
21400
|
+
* Este servicio NO maneja el login de usuarios directamente.
|
|
21401
|
+
* En su lugar, trabaja con Custom Tokens generados por tu backend.
|
|
21402
|
+
*
|
|
21403
|
+
* @example
|
|
21404
|
+
* ```typescript
|
|
21405
|
+
* // Después de autenticarte con tu backend (ej: Cognito)
|
|
21406
|
+
* @Component({...})
|
|
21407
|
+
* export class LoginComponent {
|
|
21408
|
+
* private authService = inject(AuthService); // Tu servicio de auth
|
|
21409
|
+
* private firebase = inject(FirebaseService); // Este servicio
|
|
21410
|
+
*
|
|
21411
|
+
* async login(email: string, password: string) {
|
|
21412
|
+
* // 1. Autenticar con tu backend
|
|
21413
|
+
* const response = await this.authService.login(email, password);
|
|
21414
|
+
*
|
|
21415
|
+
* // 2. El backend devuelve un Firebase Custom Token
|
|
21416
|
+
* if (response.firebaseToken) {
|
|
21417
|
+
* await this.firebase.signInWithCustomToken(response.firebaseToken);
|
|
21418
|
+
* }
|
|
21419
|
+
*
|
|
21420
|
+
* // Ahora el usuario puede acceder a Firestore, Storage, etc.
|
|
21421
|
+
* }
|
|
21422
|
+
*
|
|
21423
|
+
* async logout() {
|
|
21424
|
+
* await this.authService.logout();
|
|
21425
|
+
* await this.firebase.signOut();
|
|
21426
|
+
* }
|
|
21427
|
+
* }
|
|
21428
|
+
* ```
|
|
21429
|
+
*/
|
|
21430
|
+
class FirebaseService {
|
|
21431
|
+
constructor(auth, config) {
|
|
21432
|
+
this.auth = auth;
|
|
21433
|
+
this.config = config;
|
|
21434
|
+
/** Estado interno de la sesión */
|
|
21435
|
+
this.sessionState = new BehaviorSubject({
|
|
21436
|
+
user: null,
|
|
21437
|
+
isAuthenticated: false,
|
|
21438
|
+
isLoading: true,
|
|
21439
|
+
error: null,
|
|
21440
|
+
});
|
|
21441
|
+
/** Estado actual de la sesión como Observable */
|
|
21442
|
+
this.state$ = this.sessionState.asObservable();
|
|
21443
|
+
// Inicializar observables que dependen de auth
|
|
21444
|
+
this.user$ = authState(this.auth).pipe(map((user) => (user ? this.mapUser(user) : null)), distinctUntilChanged((a, b) => a?.uid === b?.uid));
|
|
21445
|
+
this.isAuthenticated$ = this.user$.pipe(map((user) => !!user), distinctUntilChanged());
|
|
21446
|
+
// Escuchar cambios en el estado de autenticación
|
|
21447
|
+
authState(this.auth).subscribe({
|
|
21448
|
+
next: (user) => {
|
|
21449
|
+
this.sessionState.next({
|
|
21450
|
+
user: user ? this.mapUser(user) : null,
|
|
21451
|
+
isAuthenticated: !!user,
|
|
21452
|
+
isLoading: false,
|
|
21453
|
+
error: null,
|
|
21454
|
+
});
|
|
21455
|
+
},
|
|
21456
|
+
error: (error) => {
|
|
21457
|
+
this.sessionState.next({
|
|
21458
|
+
user: null,
|
|
21459
|
+
isAuthenticated: false,
|
|
21460
|
+
isLoading: false,
|
|
21461
|
+
error,
|
|
21462
|
+
});
|
|
21463
|
+
},
|
|
21464
|
+
});
|
|
21465
|
+
}
|
|
21466
|
+
// ===========================================================================
|
|
21467
|
+
// AUTENTICACIÓN
|
|
21468
|
+
// ===========================================================================
|
|
21469
|
+
/**
|
|
21470
|
+
* Autentica al usuario con un Custom Token generado por el backend.
|
|
21471
|
+
*
|
|
21472
|
+
* @param token - Firebase Custom Token generado por tu backend
|
|
21473
|
+
* @returns UserCredential con la información del usuario
|
|
21474
|
+
* @throws Error si el token es inválido o expiró
|
|
21475
|
+
*
|
|
21476
|
+
* @example
|
|
21477
|
+
* ```typescript
|
|
21478
|
+
* // Después de login exitoso con tu backend
|
|
21479
|
+
* const { firebaseToken } = await backendAuth.login(email, password);
|
|
21480
|
+
* await firebaseService.signInWithCustomToken(firebaseToken);
|
|
21481
|
+
* ```
|
|
21482
|
+
*/
|
|
21483
|
+
async signInWithCustomToken(token) {
|
|
21484
|
+
try {
|
|
21485
|
+
const credential = await signInWithCustomToken(this.auth, token);
|
|
21486
|
+
return credential;
|
|
21487
|
+
}
|
|
21488
|
+
catch (error) {
|
|
21489
|
+
const message = this.getErrorMessage(error);
|
|
21490
|
+
throw new Error(message);
|
|
21491
|
+
}
|
|
21492
|
+
}
|
|
21493
|
+
/**
|
|
21494
|
+
* Cierra la sesión de Firebase.
|
|
21495
|
+
* Llamar junto con el logout de tu sistema de autenticación principal.
|
|
21496
|
+
*
|
|
21497
|
+
* @example
|
|
21498
|
+
* ```typescript
|
|
21499
|
+
* async logout() {
|
|
21500
|
+
* await this.backendAuth.logout(); // Tu auth
|
|
21501
|
+
* await this.firebase.signOut(); // Firebase
|
|
21502
|
+
* }
|
|
21503
|
+
* ```
|
|
21504
|
+
*/
|
|
21505
|
+
async signOut() {
|
|
21506
|
+
try {
|
|
21507
|
+
await signOut(this.auth);
|
|
21508
|
+
}
|
|
21509
|
+
catch (error) {
|
|
21510
|
+
const message = this.getErrorMessage(error);
|
|
21511
|
+
throw new Error(message);
|
|
21512
|
+
}
|
|
21513
|
+
}
|
|
21514
|
+
// ===========================================================================
|
|
21515
|
+
// GETTERS SÍNCRONOS
|
|
21516
|
+
// ===========================================================================
|
|
21517
|
+
/**
|
|
21518
|
+
* Obtiene el usuario actual de Firebase (síncrono).
|
|
21519
|
+
* Retorna null si no hay usuario autenticado.
|
|
21520
|
+
*/
|
|
21521
|
+
get currentUser() {
|
|
21522
|
+
const user = this.auth.currentUser;
|
|
21523
|
+
return user ? this.mapUser(user) : null;
|
|
21524
|
+
}
|
|
21525
|
+
/**
|
|
21526
|
+
* Obtiene el UID del usuario actual.
|
|
21527
|
+
* Retorna null si no hay usuario autenticado.
|
|
21528
|
+
*/
|
|
21529
|
+
get uid() {
|
|
21530
|
+
return this.auth.currentUser?.uid ?? null;
|
|
21531
|
+
}
|
|
21532
|
+
/**
|
|
21533
|
+
* Indica si hay un usuario autenticado actualmente.
|
|
21534
|
+
*/
|
|
21535
|
+
get isAuthenticated() {
|
|
21536
|
+
return !!this.auth.currentUser;
|
|
21537
|
+
}
|
|
21538
|
+
// ===========================================================================
|
|
21539
|
+
// TOKENS
|
|
21540
|
+
// ===========================================================================
|
|
21541
|
+
/**
|
|
21542
|
+
* Obtiene el ID Token de Firebase para el usuario actual.
|
|
21543
|
+
* Útil para validar el usuario en tu backend.
|
|
21544
|
+
*
|
|
21545
|
+
* @param forceRefresh - Si true, fuerza la renovación del token
|
|
21546
|
+
* @returns ID Token o null si no hay usuario
|
|
21547
|
+
*/
|
|
21548
|
+
async getIdToken(forceRefresh = false) {
|
|
21549
|
+
const user = this.auth.currentUser;
|
|
21550
|
+
if (!user)
|
|
21551
|
+
return null;
|
|
21552
|
+
try {
|
|
21553
|
+
return await user.getIdToken(forceRefresh);
|
|
21554
|
+
}
|
|
21555
|
+
catch {
|
|
21556
|
+
return null;
|
|
21557
|
+
}
|
|
21558
|
+
}
|
|
21559
|
+
/**
|
|
21560
|
+
* Obtiene los claims personalizados del token del usuario.
|
|
21561
|
+
* Los claims son establecidos por tu backend al crear el Custom Token.
|
|
21562
|
+
*
|
|
21563
|
+
* @returns Objeto con los claims o vacío si no hay usuario
|
|
21564
|
+
*/
|
|
21565
|
+
async getClaims() {
|
|
21566
|
+
const user = this.auth.currentUser;
|
|
21567
|
+
if (!user)
|
|
21568
|
+
return {};
|
|
21569
|
+
try {
|
|
21570
|
+
const result = await user.getIdTokenResult();
|
|
21571
|
+
return result.claims;
|
|
21572
|
+
}
|
|
21573
|
+
catch {
|
|
21574
|
+
return {};
|
|
21575
|
+
}
|
|
21576
|
+
}
|
|
21577
|
+
/**
|
|
21578
|
+
* Verifica si el usuario tiene un rol específico.
|
|
21579
|
+
* El rol debe estar definido en los claims del Custom Token.
|
|
21580
|
+
*
|
|
21581
|
+
* @param role - Nombre del rol a verificar
|
|
21582
|
+
* @returns true si el usuario tiene el rol
|
|
21583
|
+
*/
|
|
21584
|
+
async hasRole(role) {
|
|
21585
|
+
const claims = await this.getClaims();
|
|
21586
|
+
return claims['role'] === role || (Array.isArray(claims['roles']) && claims['roles'].includes(role));
|
|
21587
|
+
}
|
|
21588
|
+
// ===========================================================================
|
|
21589
|
+
// UTILIDADES
|
|
21590
|
+
// ===========================================================================
|
|
21591
|
+
/**
|
|
21592
|
+
* Espera a que el estado de autenticación esté determinado.
|
|
21593
|
+
* Útil en guards o al inicializar la app.
|
|
21594
|
+
*
|
|
21595
|
+
* @returns Usuario actual o null
|
|
21596
|
+
*/
|
|
21597
|
+
waitForAuth() {
|
|
21598
|
+
return new Promise((resolve) => {
|
|
21599
|
+
const subscription = this.state$.subscribe((state) => {
|
|
21600
|
+
if (!state.isLoading) {
|
|
21601
|
+
subscription.unsubscribe();
|
|
21602
|
+
resolve(state.user);
|
|
21603
|
+
}
|
|
21604
|
+
});
|
|
21605
|
+
});
|
|
21606
|
+
}
|
|
21607
|
+
/**
|
|
21608
|
+
* Obtiene la configuración actual de Firebase.
|
|
21609
|
+
*/
|
|
21610
|
+
getConfig() {
|
|
21611
|
+
return this.config;
|
|
21612
|
+
}
|
|
21613
|
+
/**
|
|
21614
|
+
* Indica si los emuladores están habilitados.
|
|
21615
|
+
*/
|
|
21616
|
+
isUsingEmulators() {
|
|
21617
|
+
return !!(this.config.emulator?.firestore || this.config.emulator?.auth || this.config.emulator?.storage);
|
|
21618
|
+
}
|
|
21619
|
+
// ===========================================================================
|
|
21620
|
+
// MÉTODOS PRIVADOS
|
|
21621
|
+
// ===========================================================================
|
|
21622
|
+
/**
|
|
21623
|
+
* Mapea un User de Firebase a nuestra interface FirebaseUser
|
|
21624
|
+
*/
|
|
21625
|
+
mapUser(user) {
|
|
21626
|
+
return {
|
|
21627
|
+
uid: user.uid,
|
|
21628
|
+
email: user.email,
|
|
21629
|
+
displayName: user.displayName,
|
|
21630
|
+
photoURL: user.photoURL,
|
|
21631
|
+
emailVerified: user.emailVerified,
|
|
21632
|
+
isAnonymous: user.isAnonymous,
|
|
21633
|
+
providerId: user.providerId,
|
|
21634
|
+
};
|
|
21635
|
+
}
|
|
21636
|
+
/**
|
|
21637
|
+
* Convierte errores de Firebase a mensajes en español
|
|
21638
|
+
*/
|
|
21639
|
+
getErrorMessage(error) {
|
|
21640
|
+
if (error instanceof Error) {
|
|
21641
|
+
const code = error.code;
|
|
21642
|
+
switch (code) {
|
|
21643
|
+
case 'auth/invalid-custom-token':
|
|
21644
|
+
return 'Token de autenticación inválido';
|
|
21645
|
+
case 'auth/custom-token-mismatch':
|
|
21646
|
+
return 'El token no corresponde a este proyecto';
|
|
21647
|
+
case 'auth/network-request-failed':
|
|
21648
|
+
return 'Error de conexión. Verifica tu conexión a internet';
|
|
21649
|
+
case 'auth/too-many-requests':
|
|
21650
|
+
return 'Demasiados intentos. Intenta de nuevo más tarde';
|
|
21651
|
+
case 'auth/user-disabled':
|
|
21652
|
+
return 'Esta cuenta ha sido deshabilitada';
|
|
21653
|
+
case 'auth/user-not-found':
|
|
21654
|
+
return 'Usuario no encontrado';
|
|
21655
|
+
default:
|
|
21656
|
+
return error.message || 'Error de autenticación desconocido';
|
|
21657
|
+
}
|
|
21658
|
+
}
|
|
21659
|
+
return 'Error de autenticación desconocido';
|
|
21660
|
+
}
|
|
21661
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirebaseService, deps: [{ token: i1$5.Auth }, { token: VALTECH_FIREBASE_CONFIG }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
21662
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirebaseService, providedIn: 'root' }); }
|
|
21663
|
+
}
|
|
21664
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirebaseService, decorators: [{
|
|
21665
|
+
type: Injectable,
|
|
21666
|
+
args: [{ providedIn: 'root' }]
|
|
21667
|
+
}], ctorParameters: () => [{ type: i1$5.Auth }, { type: undefined, decorators: [{
|
|
21668
|
+
type: Inject,
|
|
21669
|
+
args: [VALTECH_FIREBASE_CONFIG]
|
|
21670
|
+
}] }] });
|
|
21671
|
+
|
|
21672
|
+
/**
|
|
21673
|
+
* Path Builder
|
|
21674
|
+
*
|
|
21675
|
+
* Utilidades para construir rutas de Firestore con templates.
|
|
21676
|
+
* Soporta rutas multi-nivel y anidadas.
|
|
21677
|
+
*/
|
|
21678
|
+
/**
|
|
21679
|
+
* Construye una ruta de Firestore reemplazando placeholders.
|
|
21680
|
+
*
|
|
21681
|
+
* @param template - Template con placeholders en formato {param}
|
|
21682
|
+
* @param params - Objeto con los valores a reemplazar
|
|
21683
|
+
* @returns Ruta construida
|
|
21684
|
+
* @throws Error si faltan parámetros requeridos
|
|
21685
|
+
*
|
|
21686
|
+
* @example
|
|
21687
|
+
* ```typescript
|
|
21688
|
+
* // Ruta simple
|
|
21689
|
+
* buildPath('users/{userId}', { userId: 'abc123' });
|
|
21690
|
+
* // => 'users/abc123'
|
|
21691
|
+
*
|
|
21692
|
+
* // Ruta anidada
|
|
21693
|
+
* buildPath('users/{userId}/documents/{docId}', {
|
|
21694
|
+
* userId: 'abc123',
|
|
21695
|
+
* docId: 'doc456'
|
|
21696
|
+
* });
|
|
21697
|
+
* // => 'users/abc123/documents/doc456'
|
|
21698
|
+
*
|
|
21699
|
+
* // Múltiples niveles
|
|
21700
|
+
* buildPath('orgs/{orgId}/teams/{teamId}/members/{memberId}', {
|
|
21701
|
+
* orgId: 'org1',
|
|
21702
|
+
* teamId: 'team2',
|
|
21703
|
+
* memberId: 'member3'
|
|
21704
|
+
* });
|
|
21705
|
+
* // => 'orgs/org1/teams/team2/members/member3'
|
|
21706
|
+
* ```
|
|
21707
|
+
*/
|
|
21708
|
+
function buildPath(template, params) {
|
|
21709
|
+
let result = template;
|
|
21710
|
+
// Encontrar todos los placeholders
|
|
21711
|
+
const placeholders = template.match(/\{([^}]+)\}/g);
|
|
21712
|
+
if (!placeholders) {
|
|
21713
|
+
return template;
|
|
21714
|
+
}
|
|
21715
|
+
for (const placeholder of placeholders) {
|
|
21716
|
+
const key = placeholder.slice(1, -1); // Remover { y }
|
|
21717
|
+
const value = params[key];
|
|
21718
|
+
if (value === undefined || value === null) {
|
|
21719
|
+
throw new Error(`Parámetro requerido '${key}' no proporcionado para la ruta: ${template}`);
|
|
21720
|
+
}
|
|
21721
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
21722
|
+
throw new Error(`El parámetro '${key}' debe ser un string no vacío`);
|
|
21723
|
+
}
|
|
21724
|
+
// Validar que no contenga caracteres inválidos para Firestore
|
|
21725
|
+
if (value.includes('/')) {
|
|
21726
|
+
throw new Error(`El parámetro '${key}' no puede contener '/'`);
|
|
21727
|
+
}
|
|
21728
|
+
result = result.replace(placeholder, value);
|
|
21729
|
+
}
|
|
21730
|
+
return result;
|
|
21731
|
+
}
|
|
21732
|
+
/**
|
|
21733
|
+
* Extrae los nombres de los parámetros de un template de ruta.
|
|
21734
|
+
*
|
|
21735
|
+
* @param template - Template de ruta
|
|
21736
|
+
* @returns Array con los nombres de los parámetros
|
|
21737
|
+
*
|
|
21738
|
+
* @example
|
|
21739
|
+
* ```typescript
|
|
21740
|
+
* extractParams('users/{userId}/documents/{docId}');
|
|
21741
|
+
* // => ['userId', 'docId']
|
|
21742
|
+
* ```
|
|
21743
|
+
*/
|
|
21744
|
+
function extractPathParams(template) {
|
|
21745
|
+
const matches = template.match(/\{([^}]+)\}/g);
|
|
21746
|
+
if (!matches)
|
|
21747
|
+
return [];
|
|
21748
|
+
return matches.map((m) => m.slice(1, -1));
|
|
21749
|
+
}
|
|
21750
|
+
/**
|
|
21751
|
+
* Valida que una ruta de Firestore sea válida.
|
|
21752
|
+
*
|
|
21753
|
+
* @param path - Ruta a validar
|
|
21754
|
+
* @returns true si la ruta es válida
|
|
21755
|
+
*
|
|
21756
|
+
* @example
|
|
21757
|
+
* ```typescript
|
|
21758
|
+
* isValidPath('users/abc123'); // true
|
|
21759
|
+
* isValidPath('users/abc123/documents'); // true
|
|
21760
|
+
* isValidPath('users//documents'); // false (segmento vacío)
|
|
21761
|
+
* isValidPath(''); // false (vacío)
|
|
21762
|
+
* ```
|
|
21763
|
+
*/
|
|
21764
|
+
function isValidPath(path) {
|
|
21765
|
+
if (!path || path.trim() === '')
|
|
21766
|
+
return false;
|
|
21767
|
+
const segments = path.split('/');
|
|
21768
|
+
// No puede tener segmentos vacíos
|
|
21769
|
+
if (segments.some((s) => s.trim() === ''))
|
|
21770
|
+
return false;
|
|
21771
|
+
// No puede empezar o terminar con /
|
|
21772
|
+
if (path.startsWith('/') || path.endsWith('/'))
|
|
21773
|
+
return false;
|
|
21774
|
+
return true;
|
|
21775
|
+
}
|
|
21776
|
+
/**
|
|
21777
|
+
* Obtiene la ruta de la colección padre de un documento.
|
|
21778
|
+
*
|
|
21779
|
+
* @param documentPath - Ruta completa del documento
|
|
21780
|
+
* @returns Ruta de la colección padre
|
|
21781
|
+
*
|
|
21782
|
+
* @example
|
|
21783
|
+
* ```typescript
|
|
21784
|
+
* getCollectionPath('users/abc123');
|
|
21785
|
+
* // => 'users'
|
|
21786
|
+
*
|
|
21787
|
+
* getCollectionPath('users/abc123/documents/doc456');
|
|
21788
|
+
* // => 'users/abc123/documents'
|
|
21789
|
+
* ```
|
|
21790
|
+
*/
|
|
21791
|
+
function getCollectionPath(documentPath) {
|
|
21792
|
+
const segments = documentPath.split('/');
|
|
21793
|
+
if (segments.length < 2) {
|
|
21794
|
+
throw new Error(`Ruta de documento inválida: ${documentPath}`);
|
|
21795
|
+
}
|
|
21796
|
+
return segments.slice(0, -1).join('/');
|
|
21797
|
+
}
|
|
21798
|
+
/**
|
|
21799
|
+
* Obtiene el ID del documento de una ruta.
|
|
21800
|
+
*
|
|
21801
|
+
* @param documentPath - Ruta completa del documento
|
|
21802
|
+
* @returns ID del documento
|
|
21803
|
+
*
|
|
21804
|
+
* @example
|
|
21805
|
+
* ```typescript
|
|
21806
|
+
* getDocumentId('users/abc123');
|
|
21807
|
+
* // => 'abc123'
|
|
21808
|
+
*
|
|
21809
|
+
* getDocumentId('users/abc123/documents/doc456');
|
|
21810
|
+
* // => 'doc456'
|
|
21811
|
+
* ```
|
|
21812
|
+
*/
|
|
21813
|
+
function getDocumentId(documentPath) {
|
|
21814
|
+
const segments = documentPath.split('/');
|
|
21815
|
+
if (segments.length < 2 || segments.length % 2 !== 0) {
|
|
21816
|
+
throw new Error(`Ruta de documento inválida: ${documentPath}`);
|
|
21817
|
+
}
|
|
21818
|
+
return segments[segments.length - 1];
|
|
21819
|
+
}
|
|
21820
|
+
/**
|
|
21821
|
+
* Verifica si una ruta apunta a un documento (número par de segmentos).
|
|
21822
|
+
*
|
|
21823
|
+
* @param path - Ruta a verificar
|
|
21824
|
+
* @returns true si es una ruta de documento
|
|
21825
|
+
*
|
|
21826
|
+
* @example
|
|
21827
|
+
* ```typescript
|
|
21828
|
+
* isDocumentPath('users/abc123'); // true
|
|
21829
|
+
* isDocumentPath('users'); // false (colección)
|
|
21830
|
+
* isDocumentPath('users/abc123/documents'); // false (colección)
|
|
21831
|
+
* ```
|
|
21832
|
+
*/
|
|
21833
|
+
function isDocumentPath(path) {
|
|
21834
|
+
const segments = path.split('/').filter((s) => s.trim() !== '');
|
|
21835
|
+
return segments.length > 0 && segments.length % 2 === 0;
|
|
21836
|
+
}
|
|
21837
|
+
/**
|
|
21838
|
+
* Verifica si una ruta apunta a una colección (número impar de segmentos).
|
|
21839
|
+
*
|
|
21840
|
+
* @param path - Ruta a verificar
|
|
21841
|
+
* @returns true si es una ruta de colección
|
|
21842
|
+
*/
|
|
21843
|
+
function isCollectionPath(path) {
|
|
21844
|
+
const segments = path.split('/').filter((s) => s.trim() !== '');
|
|
21845
|
+
return segments.length > 0 && segments.length % 2 !== 0;
|
|
21846
|
+
}
|
|
21847
|
+
/**
|
|
21848
|
+
* Combina una ruta base con segmentos adicionales.
|
|
21849
|
+
*
|
|
21850
|
+
* @param basePath - Ruta base
|
|
21851
|
+
* @param segments - Segmentos adicionales
|
|
21852
|
+
* @returns Ruta combinada
|
|
21853
|
+
*
|
|
21854
|
+
* @example
|
|
21855
|
+
* ```typescript
|
|
21856
|
+
* joinPath('users', 'abc123', 'documents');
|
|
21857
|
+
* // => 'users/abc123/documents'
|
|
21858
|
+
* ```
|
|
21859
|
+
*/
|
|
21860
|
+
function joinPath(...segments) {
|
|
21861
|
+
return segments
|
|
21862
|
+
.filter((s) => s && s.trim() !== '')
|
|
21863
|
+
.map((s) => s.replace(/^\/+|\/+$/g, '')) // Remover / al inicio y final
|
|
21864
|
+
.join('/');
|
|
21865
|
+
}
|
|
21866
|
+
|
|
21867
|
+
/**
|
|
21868
|
+
* Firestore Service
|
|
21869
|
+
*
|
|
21870
|
+
* Servicio genérico para operaciones CRUD en Firestore.
|
|
21871
|
+
* Soporta lecturas one-time, subscripciones real-time, paginación y queries complejas.
|
|
21872
|
+
*/
|
|
21873
|
+
/**
|
|
21874
|
+
* Servicio para operaciones CRUD en Firestore.
|
|
21875
|
+
*
|
|
21876
|
+
* @example
|
|
21877
|
+
* ```typescript
|
|
21878
|
+
* interface User extends FirestoreDocument {
|
|
21879
|
+
* name: string;
|
|
21880
|
+
* email: string;
|
|
21881
|
+
* role: 'admin' | 'user';
|
|
21882
|
+
* }
|
|
21883
|
+
*
|
|
21884
|
+
* @Component({...})
|
|
21885
|
+
* export class UsersComponent {
|
|
21886
|
+
* private firestore = inject(FirestoreService);
|
|
21887
|
+
*
|
|
21888
|
+
* // Lectura one-time
|
|
21889
|
+
* async loadUser(id: string) {
|
|
21890
|
+
* const user = await this.firestore.getDoc<User>('users', id);
|
|
21891
|
+
* }
|
|
21892
|
+
*
|
|
21893
|
+
* // Subscripción real-time
|
|
21894
|
+
* users$ = this.firestore.collectionChanges<User>('users', {
|
|
21895
|
+
* where: [{ field: 'role', operator: '==', value: 'admin' }],
|
|
21896
|
+
* orderBy: [{ field: 'name', direction: 'asc' }]
|
|
21897
|
+
* });
|
|
21898
|
+
*
|
|
21899
|
+
* // Crear documento
|
|
21900
|
+
* async createUser(data: Omit<User, 'id'>) {
|
|
21901
|
+
* const user = await this.firestore.addDoc<User>('users', data);
|
|
21902
|
+
* }
|
|
21903
|
+
* }
|
|
21904
|
+
* ```
|
|
21905
|
+
*/
|
|
21906
|
+
class FirestoreService {
|
|
21907
|
+
constructor(firestore) {
|
|
21908
|
+
this.firestore = firestore;
|
|
21909
|
+
}
|
|
21910
|
+
// ===========================================================================
|
|
21911
|
+
// LECTURAS ONE-TIME (Promise)
|
|
21912
|
+
// ===========================================================================
|
|
21913
|
+
/**
|
|
21914
|
+
* Obtiene un documento por ID (lectura única).
|
|
21915
|
+
*
|
|
21916
|
+
* @param collectionPath - Ruta de la colección
|
|
21917
|
+
* @param docId - ID del documento
|
|
21918
|
+
* @returns Documento o null si no existe
|
|
21919
|
+
*
|
|
21920
|
+
* @example
|
|
21921
|
+
* ```typescript
|
|
21922
|
+
* const user = await firestoreService.getDoc<User>('users', 'abc123');
|
|
21923
|
+
* if (user) {
|
|
21924
|
+
* console.log(user.name);
|
|
21925
|
+
* }
|
|
21926
|
+
* ```
|
|
21927
|
+
*/
|
|
21928
|
+
async getDoc(collectionPath, docId) {
|
|
21929
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
21930
|
+
const snapshot = await getDoc(docRef);
|
|
21931
|
+
if (!snapshot.exists()) {
|
|
21932
|
+
return null;
|
|
21933
|
+
}
|
|
21934
|
+
return this.mapDocument(snapshot);
|
|
21935
|
+
}
|
|
21936
|
+
/**
|
|
21937
|
+
* Obtiene múltiples documentos con opciones de query.
|
|
21938
|
+
*
|
|
21939
|
+
* @param collectionPath - Ruta de la colección
|
|
21940
|
+
* @param options - Opciones de query (where, orderBy, limit)
|
|
21941
|
+
* @returns Array de documentos
|
|
21942
|
+
*
|
|
21943
|
+
* @example
|
|
21944
|
+
* ```typescript
|
|
21945
|
+
* // Todos los usuarios activos ordenados por nombre
|
|
21946
|
+
* const users = await firestoreService.getDocs<User>('users', {
|
|
21947
|
+
* where: [{ field: 'active', operator: '==', value: true }],
|
|
21948
|
+
* orderBy: [{ field: 'name', direction: 'asc' }],
|
|
21949
|
+
* limit: 50
|
|
21950
|
+
* });
|
|
21951
|
+
* ```
|
|
21952
|
+
*/
|
|
21953
|
+
async getDocs(collectionPath, options) {
|
|
21954
|
+
const collectionRef = collection(this.firestore, collectionPath);
|
|
21955
|
+
const constraints = this.buildQueryConstraints(options);
|
|
21956
|
+
const q = query$1(collectionRef, ...constraints);
|
|
21957
|
+
const snapshot = await getDocs(q);
|
|
21958
|
+
return snapshot.docs.map((doc) => this.mapDocument(doc));
|
|
21959
|
+
}
|
|
21960
|
+
/**
|
|
21961
|
+
* Obtiene documentos con paginación basada en cursores.
|
|
21962
|
+
*
|
|
21963
|
+
* @param collectionPath - Ruta de la colección
|
|
21964
|
+
* @param options - Opciones de query (debe incluir limit)
|
|
21965
|
+
* @returns Resultado paginado con cursor para la siguiente página
|
|
21966
|
+
*
|
|
21967
|
+
* @example
|
|
21968
|
+
* ```typescript
|
|
21969
|
+
* // Primera página
|
|
21970
|
+
* const page1 = await firestoreService.getPaginated<User>('users', {
|
|
21971
|
+
* orderBy: [{ field: 'createdAt', direction: 'desc' }],
|
|
21972
|
+
* limit: 10
|
|
21973
|
+
* });
|
|
21974
|
+
*
|
|
21975
|
+
* // Siguiente página
|
|
21976
|
+
* if (page1.hasMore) {
|
|
21977
|
+
* const page2 = await firestoreService.getPaginated<User>('users', {
|
|
21978
|
+
* orderBy: [{ field: 'createdAt', direction: 'desc' }],
|
|
21979
|
+
* limit: 10,
|
|
21980
|
+
* startAfter: page1.lastDoc
|
|
21981
|
+
* });
|
|
21982
|
+
* }
|
|
21983
|
+
* ```
|
|
21984
|
+
*/
|
|
21985
|
+
async getPaginated(collectionPath, options) {
|
|
21986
|
+
const collectionRef = collection(this.firestore, collectionPath);
|
|
21987
|
+
const constraints = this.buildQueryConstraints(options);
|
|
21988
|
+
// Pedir uno más para saber si hay más páginas
|
|
21989
|
+
const q = query$1(collectionRef, ...constraints, limit(options.limit + 1));
|
|
21990
|
+
const snapshot = await getDocs(q);
|
|
21991
|
+
const docs = snapshot.docs;
|
|
21992
|
+
const hasMore = docs.length > options.limit;
|
|
21993
|
+
// Si hay más, remover el documento extra
|
|
21994
|
+
const resultDocs = hasMore ? docs.slice(0, -1) : docs;
|
|
21995
|
+
const lastDoc = resultDocs.length > 0 ? resultDocs[resultDocs.length - 1] : null;
|
|
21996
|
+
return {
|
|
21997
|
+
data: resultDocs.map((doc) => this.mapDocument(doc)),
|
|
21998
|
+
hasMore,
|
|
21999
|
+
lastDoc,
|
|
22000
|
+
};
|
|
22001
|
+
}
|
|
22002
|
+
/**
|
|
22003
|
+
* Verifica si un documento existe.
|
|
22004
|
+
*
|
|
22005
|
+
* @param collectionPath - Ruta de la colección
|
|
22006
|
+
* @param docId - ID del documento
|
|
22007
|
+
* @returns true si el documento existe
|
|
22008
|
+
*/
|
|
22009
|
+
async exists(collectionPath, docId) {
|
|
22010
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
22011
|
+
const snapshot = await getDoc(docRef);
|
|
22012
|
+
return snapshot.exists();
|
|
22013
|
+
}
|
|
22014
|
+
// ===========================================================================
|
|
22015
|
+
// SUBSCRIPCIONES REAL-TIME (Observable)
|
|
22016
|
+
// ===========================================================================
|
|
22017
|
+
/**
|
|
22018
|
+
* Suscribe a cambios de un documento (real-time).
|
|
22019
|
+
*
|
|
22020
|
+
* @param collectionPath - Ruta de la colección
|
|
22021
|
+
* @param docId - ID del documento
|
|
22022
|
+
* @returns Observable que emite cuando el documento cambia
|
|
22023
|
+
*
|
|
22024
|
+
* @example
|
|
22025
|
+
* ```typescript
|
|
22026
|
+
* // En el componente
|
|
22027
|
+
* user$ = this.firestoreService.docChanges<User>('users', this.userId);
|
|
22028
|
+
*
|
|
22029
|
+
* // En el template
|
|
22030
|
+
* @if (user$ | async; as user) {
|
|
22031
|
+
* <p>{{ user.name }}</p>
|
|
22032
|
+
* }
|
|
22033
|
+
* ```
|
|
22034
|
+
*/
|
|
22035
|
+
docChanges(collectionPath, docId) {
|
|
22036
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
22037
|
+
return docData(docRef, { idField: 'id' }).pipe(map((data) => {
|
|
22038
|
+
if (!data)
|
|
22039
|
+
return null;
|
|
22040
|
+
return this.convertTimestamps(data);
|
|
22041
|
+
}));
|
|
22042
|
+
}
|
|
22043
|
+
/**
|
|
22044
|
+
* Suscribe a cambios de una colección (real-time).
|
|
22045
|
+
*
|
|
22046
|
+
* @param collectionPath - Ruta de la colección
|
|
22047
|
+
* @param options - Opciones de query
|
|
22048
|
+
* @returns Observable que emite cuando la colección cambia
|
|
22049
|
+
*
|
|
22050
|
+
* @example
|
|
22051
|
+
* ```typescript
|
|
22052
|
+
* // Usuarios activos en tiempo real
|
|
22053
|
+
* activeUsers$ = this.firestoreService.collectionChanges<User>('users', {
|
|
22054
|
+
* where: [{ field: 'status', operator: '==', value: 'online' }]
|
|
22055
|
+
* });
|
|
22056
|
+
* ```
|
|
22057
|
+
*/
|
|
22058
|
+
collectionChanges(collectionPath, options) {
|
|
22059
|
+
const collectionRef = collection(this.firestore, collectionPath);
|
|
22060
|
+
const constraints = this.buildQueryConstraints(options);
|
|
22061
|
+
const q = query$1(collectionRef, ...constraints);
|
|
22062
|
+
return collectionData(q, { idField: 'id' }).pipe(map((docs) => docs.map((doc) => this.convertTimestamps(doc))));
|
|
22063
|
+
}
|
|
22064
|
+
// ===========================================================================
|
|
22065
|
+
// ESCRITURA
|
|
22066
|
+
// ===========================================================================
|
|
22067
|
+
/**
|
|
22068
|
+
* Agrega un documento con ID auto-generado.
|
|
22069
|
+
*
|
|
22070
|
+
* @param collectionPath - Ruta de la colección
|
|
22071
|
+
* @param data - Datos del documento (sin id, createdAt, updatedAt)
|
|
22072
|
+
* @returns Documento creado con su ID
|
|
22073
|
+
*
|
|
22074
|
+
* @example
|
|
22075
|
+
* ```typescript
|
|
22076
|
+
* const newUser = await firestoreService.addDoc<User>('users', {
|
|
22077
|
+
* name: 'John Doe',
|
|
22078
|
+
* email: 'john@example.com',
|
|
22079
|
+
* role: 'user'
|
|
22080
|
+
* });
|
|
22081
|
+
* console.log('Created user with ID:', newUser.id);
|
|
22082
|
+
* ```
|
|
22083
|
+
*/
|
|
22084
|
+
async addDoc(collectionPath, data) {
|
|
22085
|
+
const collectionRef = collection(this.firestore, collectionPath);
|
|
22086
|
+
const timestamp = serverTimestamp();
|
|
22087
|
+
const docData = {
|
|
22088
|
+
...data,
|
|
22089
|
+
createdAt: timestamp,
|
|
22090
|
+
updatedAt: timestamp,
|
|
22091
|
+
};
|
|
22092
|
+
const docRef = await addDoc(collectionRef, docData);
|
|
22093
|
+
// Obtener el documento creado para retornarlo con timestamps resueltos
|
|
22094
|
+
const snapshot = await getDoc(docRef);
|
|
22095
|
+
return this.mapDocument(snapshot);
|
|
22096
|
+
}
|
|
22097
|
+
/**
|
|
22098
|
+
* Crea o sobrescribe un documento con ID específico.
|
|
22099
|
+
*
|
|
22100
|
+
* @param collectionPath - Ruta de la colección
|
|
22101
|
+
* @param docId - ID del documento
|
|
22102
|
+
* @param data - Datos del documento
|
|
22103
|
+
* @param options - Opciones (merge: true para merge en lugar de sobrescribir)
|
|
22104
|
+
*
|
|
22105
|
+
* @example
|
|
22106
|
+
* ```typescript
|
|
22107
|
+
* // Sobrescribir completamente
|
|
22108
|
+
* await firestoreService.setDoc<User>('users', 'user123', userData);
|
|
22109
|
+
*
|
|
22110
|
+
* // Merge con datos existentes
|
|
22111
|
+
* await firestoreService.setDoc<User>('users', 'user123', { name: 'New Name' }, { merge: true });
|
|
22112
|
+
* ```
|
|
22113
|
+
*/
|
|
22114
|
+
async setDoc(collectionPath, docId, data, options) {
|
|
22115
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
22116
|
+
const timestamp = serverTimestamp();
|
|
22117
|
+
const docData = {
|
|
22118
|
+
...data,
|
|
22119
|
+
updatedAt: timestamp,
|
|
22120
|
+
...(options?.merge ? {} : { createdAt: timestamp }),
|
|
22121
|
+
};
|
|
22122
|
+
await setDoc(docRef, docData, { merge: options?.merge ?? false });
|
|
22123
|
+
}
|
|
22124
|
+
/**
|
|
22125
|
+
* Actualiza campos específicos de un documento.
|
|
22126
|
+
*
|
|
22127
|
+
* @param collectionPath - Ruta de la colección
|
|
22128
|
+
* @param docId - ID del documento
|
|
22129
|
+
* @param data - Campos a actualizar
|
|
22130
|
+
*
|
|
22131
|
+
* @example
|
|
22132
|
+
* ```typescript
|
|
22133
|
+
* await firestoreService.updateDoc<User>('users', 'user123', {
|
|
22134
|
+
* name: 'Updated Name',
|
|
22135
|
+
* lastLogin: new Date()
|
|
22136
|
+
* });
|
|
22137
|
+
* ```
|
|
22138
|
+
*/
|
|
22139
|
+
async updateDoc(collectionPath, docId, data) {
|
|
22140
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
22141
|
+
await updateDoc(docRef, {
|
|
22142
|
+
...data,
|
|
22143
|
+
updatedAt: serverTimestamp(),
|
|
22144
|
+
});
|
|
22145
|
+
}
|
|
22146
|
+
/**
|
|
22147
|
+
* Elimina un documento.
|
|
22148
|
+
*
|
|
22149
|
+
* @param collectionPath - Ruta de la colección
|
|
22150
|
+
* @param docId - ID del documento
|
|
22151
|
+
*
|
|
22152
|
+
* @example
|
|
22153
|
+
* ```typescript
|
|
22154
|
+
* await firestoreService.deleteDoc('users', 'user123');
|
|
22155
|
+
* ```
|
|
22156
|
+
*/
|
|
22157
|
+
async deleteDoc(collectionPath, docId) {
|
|
22158
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
22159
|
+
await deleteDoc(docRef);
|
|
22160
|
+
}
|
|
22161
|
+
// ===========================================================================
|
|
22162
|
+
// OPERACIONES EN LOTE
|
|
22163
|
+
// ===========================================================================
|
|
22164
|
+
/**
|
|
22165
|
+
* Ejecuta múltiples operaciones de escritura de forma atómica.
|
|
22166
|
+
*
|
|
22167
|
+
* @param operations - Función que recibe el batch y agrega operaciones
|
|
22168
|
+
*
|
|
22169
|
+
* @example
|
|
22170
|
+
* ```typescript
|
|
22171
|
+
* await firestoreService.batch((batch) => {
|
|
22172
|
+
* batch.set('users/user1', { name: 'User 1' });
|
|
22173
|
+
* batch.update('users/user2', { status: 'inactive' });
|
|
22174
|
+
* batch.delete('users/user3');
|
|
22175
|
+
* });
|
|
22176
|
+
* ```
|
|
22177
|
+
*/
|
|
22178
|
+
async batch(operations) {
|
|
22179
|
+
const batch = writeBatch(this.firestore);
|
|
22180
|
+
const batchApi = {
|
|
22181
|
+
set: (path, data) => {
|
|
22182
|
+
const [collectionPath, docId] = this.splitPath(path);
|
|
22183
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
22184
|
+
batch.set(docRef, {
|
|
22185
|
+
...data,
|
|
22186
|
+
createdAt: serverTimestamp(),
|
|
22187
|
+
updatedAt: serverTimestamp(),
|
|
22188
|
+
});
|
|
22189
|
+
},
|
|
22190
|
+
update: (path, data) => {
|
|
22191
|
+
const [collectionPath, docId] = this.splitPath(path);
|
|
22192
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
22193
|
+
batch.update(docRef, {
|
|
22194
|
+
...data,
|
|
22195
|
+
updatedAt: serverTimestamp(),
|
|
22196
|
+
});
|
|
22197
|
+
},
|
|
22198
|
+
delete: (path) => {
|
|
22199
|
+
const [collectionPath, docId] = this.splitPath(path);
|
|
22200
|
+
const docRef = doc(this.firestore, collectionPath, docId);
|
|
22201
|
+
batch.delete(docRef);
|
|
22202
|
+
},
|
|
22203
|
+
};
|
|
22204
|
+
operations(batchApi);
|
|
22205
|
+
await batch.commit();
|
|
22206
|
+
}
|
|
22207
|
+
// ===========================================================================
|
|
22208
|
+
// UTILIDADES
|
|
22209
|
+
// ===========================================================================
|
|
22210
|
+
/**
|
|
22211
|
+
* Construye una ruta a partir de un template.
|
|
22212
|
+
*
|
|
22213
|
+
* @param template - Template con placeholders {param}
|
|
22214
|
+
* @param params - Valores para los placeholders
|
|
22215
|
+
* @returns Ruta construida
|
|
22216
|
+
*
|
|
22217
|
+
* @example
|
|
22218
|
+
* ```typescript
|
|
22219
|
+
* const path = firestoreService.buildPath('users/{userId}/documents/{docId}', {
|
|
22220
|
+
* userId: 'user123',
|
|
22221
|
+
* docId: 'doc456'
|
|
22222
|
+
* });
|
|
22223
|
+
* // => 'users/user123/documents/doc456'
|
|
22224
|
+
* ```
|
|
22225
|
+
*/
|
|
22226
|
+
buildPath(template, params) {
|
|
22227
|
+
return buildPath(template, params);
|
|
22228
|
+
}
|
|
22229
|
+
/**
|
|
22230
|
+
* Genera un ID único para un documento (sin crearlo).
|
|
22231
|
+
*
|
|
22232
|
+
* @param collectionPath - Ruta de la colección
|
|
22233
|
+
* @returns ID único generado por Firestore
|
|
22234
|
+
*/
|
|
22235
|
+
generateId(collectionPath) {
|
|
22236
|
+
const collectionRef = collection(this.firestore, collectionPath);
|
|
22237
|
+
return doc(collectionRef).id;
|
|
22238
|
+
}
|
|
22239
|
+
/**
|
|
22240
|
+
* Retorna un valor de timestamp del servidor.
|
|
22241
|
+
* Usar en campos de fecha para que Firestore asigne el timestamp.
|
|
22242
|
+
*/
|
|
22243
|
+
serverTimestamp() {
|
|
22244
|
+
return serverTimestamp();
|
|
22245
|
+
}
|
|
22246
|
+
/**
|
|
22247
|
+
* Retorna un valor para agregar elementos a un array.
|
|
22248
|
+
*
|
|
22249
|
+
* @example
|
|
22250
|
+
* ```typescript
|
|
22251
|
+
* await firestoreService.updateDoc('users', 'user123', {
|
|
22252
|
+
* tags: firestoreService.arrayUnion('new-tag')
|
|
22253
|
+
* });
|
|
22254
|
+
* ```
|
|
22255
|
+
*/
|
|
22256
|
+
arrayUnion(...elements) {
|
|
22257
|
+
return arrayUnion(...elements);
|
|
22258
|
+
}
|
|
22259
|
+
/**
|
|
22260
|
+
* Retorna un valor para remover elementos de un array.
|
|
22261
|
+
*/
|
|
22262
|
+
arrayRemove(...elements) {
|
|
22263
|
+
return arrayRemove(...elements);
|
|
22264
|
+
}
|
|
22265
|
+
/**
|
|
22266
|
+
* Retorna un valor para incrementar un campo numérico.
|
|
22267
|
+
*
|
|
22268
|
+
* @example
|
|
22269
|
+
* ```typescript
|
|
22270
|
+
* await firestoreService.updateDoc('users', 'user123', {
|
|
22271
|
+
* loginCount: firestoreService.increment(1)
|
|
22272
|
+
* });
|
|
22273
|
+
* ```
|
|
22274
|
+
*/
|
|
22275
|
+
increment(n) {
|
|
22276
|
+
return increment(n);
|
|
22277
|
+
}
|
|
22278
|
+
// ===========================================================================
|
|
22279
|
+
// MÉTODOS PRIVADOS
|
|
22280
|
+
// ===========================================================================
|
|
22281
|
+
/**
|
|
22282
|
+
* Construye los QueryConstraints a partir de QueryOptions
|
|
22283
|
+
*/
|
|
22284
|
+
buildQueryConstraints(options) {
|
|
22285
|
+
const constraints = [];
|
|
22286
|
+
if (!options)
|
|
22287
|
+
return constraints;
|
|
22288
|
+
// Where clauses
|
|
22289
|
+
if (options.where) {
|
|
22290
|
+
for (const clause of options.where) {
|
|
22291
|
+
constraints.push(where(clause.field, clause.operator, clause.value));
|
|
22292
|
+
}
|
|
22293
|
+
}
|
|
22294
|
+
// OrderBy clauses
|
|
22295
|
+
if (options.orderBy) {
|
|
22296
|
+
for (const clause of options.orderBy) {
|
|
22297
|
+
constraints.push(orderBy(clause.field, clause.direction));
|
|
22298
|
+
}
|
|
22299
|
+
}
|
|
22300
|
+
// Cursors para paginación
|
|
22301
|
+
if (options.startAfter) {
|
|
22302
|
+
constraints.push(startAfter(options.startAfter));
|
|
22303
|
+
}
|
|
22304
|
+
if (options.startAt) {
|
|
22305
|
+
constraints.push(startAt(options.startAt));
|
|
22306
|
+
}
|
|
22307
|
+
if (options.endBefore) {
|
|
22308
|
+
constraints.push(endBefore(options.endBefore));
|
|
22309
|
+
}
|
|
22310
|
+
if (options.endAt) {
|
|
22311
|
+
constraints.push(endAt(options.endAt));
|
|
22312
|
+
}
|
|
22313
|
+
// Limit (se agrega al final)
|
|
22314
|
+
if (options.limit) {
|
|
22315
|
+
constraints.push(limit(options.limit));
|
|
22316
|
+
}
|
|
22317
|
+
return constraints;
|
|
22318
|
+
}
|
|
22319
|
+
/**
|
|
22320
|
+
* Mapea un DocumentSnapshot a nuestro tipo
|
|
22321
|
+
*/
|
|
22322
|
+
mapDocument(snapshot) {
|
|
22323
|
+
const data = snapshot.data();
|
|
22324
|
+
if (!data) {
|
|
22325
|
+
throw new Error('Documento no tiene datos');
|
|
22326
|
+
}
|
|
22327
|
+
return {
|
|
22328
|
+
id: snapshot.id,
|
|
22329
|
+
...this.convertTimestamps(data),
|
|
22330
|
+
};
|
|
22331
|
+
}
|
|
22332
|
+
/**
|
|
22333
|
+
* Convierte Timestamps de Firestore a Date de JavaScript
|
|
22334
|
+
*/
|
|
22335
|
+
convertTimestamps(data) {
|
|
22336
|
+
const result = {};
|
|
22337
|
+
for (const [key, value] of Object.entries(data)) {
|
|
22338
|
+
if (value instanceof Timestamp) {
|
|
22339
|
+
result[key] = value.toDate();
|
|
22340
|
+
}
|
|
22341
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
22342
|
+
result[key] = this.convertTimestamps(value);
|
|
22343
|
+
}
|
|
22344
|
+
else {
|
|
22345
|
+
result[key] = value;
|
|
22346
|
+
}
|
|
22347
|
+
}
|
|
22348
|
+
return result;
|
|
22349
|
+
}
|
|
22350
|
+
/**
|
|
22351
|
+
* Divide una ruta de documento en colección e ID
|
|
22352
|
+
*/
|
|
22353
|
+
splitPath(path) {
|
|
22354
|
+
const segments = path.split('/');
|
|
22355
|
+
if (segments.length < 2 || segments.length % 2 !== 0) {
|
|
22356
|
+
throw new Error(`Ruta de documento inválida: ${path}`);
|
|
22357
|
+
}
|
|
22358
|
+
const docId = segments.pop();
|
|
22359
|
+
const collectionPath = segments.join('/');
|
|
22360
|
+
return [collectionPath, docId];
|
|
22361
|
+
}
|
|
22362
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirestoreService, deps: [{ token: i1$6.Firestore }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
22363
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirestoreService, providedIn: 'root' }); }
|
|
22364
|
+
}
|
|
22365
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirestoreService, decorators: [{
|
|
22366
|
+
type: Injectable,
|
|
22367
|
+
args: [{ providedIn: 'root' }]
|
|
22368
|
+
}], ctorParameters: () => [{ type: i1$6.Firestore }] });
|
|
22369
|
+
|
|
22370
|
+
/**
|
|
22371
|
+
* Firestore Collection Factory
|
|
22372
|
+
*
|
|
22373
|
+
* Patrón factory para crear instancias de colección tipadas.
|
|
22374
|
+
* Reemplaza la clase abstracta para evitar problemas con inject() en clases no-injectable.
|
|
22375
|
+
*/
|
|
22376
|
+
/**
|
|
22377
|
+
* Factory para crear instancias de colección tipadas.
|
|
22378
|
+
*
|
|
22379
|
+
* @example
|
|
22380
|
+
* ```typescript
|
|
22381
|
+
* @Injectable({ providedIn: 'root' })
|
|
22382
|
+
* export class UsersService {
|
|
22383
|
+
* private users = inject(FirestoreCollectionFactory).create<User>('users');
|
|
22384
|
+
*
|
|
22385
|
+
* getAll = () => this.users.getAll();
|
|
22386
|
+
* getById = (id: string) => this.users.getById(id);
|
|
22387
|
+
* create = (data: Omit<User, 'id'>) => this.users.create(data);
|
|
22388
|
+
*
|
|
22389
|
+
* // Métodos personalizados
|
|
22390
|
+
* async getActiveUsers(): Promise<User[]> {
|
|
22391
|
+
* return this.users.query({
|
|
22392
|
+
* where: [{ field: 'active', operator: '==', value: true }]
|
|
22393
|
+
* });
|
|
22394
|
+
* }
|
|
22395
|
+
* }
|
|
22396
|
+
* ```
|
|
22397
|
+
*/
|
|
22398
|
+
class FirestoreCollectionFactory {
|
|
22399
|
+
constructor(firestore) {
|
|
22400
|
+
this.firestore = firestore;
|
|
22401
|
+
}
|
|
22402
|
+
/**
|
|
22403
|
+
* Crea una instancia de colección tipada.
|
|
22404
|
+
*
|
|
22405
|
+
* @param collectionPath - Ruta de la colección en Firestore
|
|
22406
|
+
* @param options - Opciones de configuración
|
|
22407
|
+
* @returns Instancia de TypedCollection
|
|
22408
|
+
*/
|
|
22409
|
+
create(collectionPath, options) {
|
|
22410
|
+
return new TypedCollection(this.firestore, collectionPath, options);
|
|
22411
|
+
}
|
|
22412
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirestoreCollectionFactory, deps: [{ token: FirestoreService }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
22413
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirestoreCollectionFactory, providedIn: 'root' }); }
|
|
22414
|
+
}
|
|
22415
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: FirestoreCollectionFactory, decorators: [{
|
|
22416
|
+
type: Injectable,
|
|
22417
|
+
args: [{ providedIn: 'root' }]
|
|
22418
|
+
}], ctorParameters: () => [{ type: FirestoreService }] });
|
|
22419
|
+
/**
|
|
22420
|
+
* Colección tipada con métodos CRUD.
|
|
22421
|
+
*
|
|
22422
|
+
* NO usa inject() - recibe FirestoreService por constructor.
|
|
22423
|
+
* Esto evita el error NG0203.
|
|
22424
|
+
*/
|
|
22425
|
+
class TypedCollection {
|
|
22426
|
+
constructor(firestore, collectionPath, options = {}) {
|
|
22427
|
+
this.firestore = firestore;
|
|
22428
|
+
this.collectionPath = collectionPath;
|
|
22429
|
+
this.options = {
|
|
22430
|
+
softDelete: false,
|
|
22431
|
+
timestamps: true,
|
|
22432
|
+
...options,
|
|
22433
|
+
};
|
|
22434
|
+
}
|
|
22435
|
+
// ===========================================================================
|
|
22436
|
+
// LECTURAS ONE-TIME
|
|
22437
|
+
// ===========================================================================
|
|
22438
|
+
/**
|
|
22439
|
+
* Obtiene un documento por ID.
|
|
22440
|
+
*/
|
|
22441
|
+
async getById(id) {
|
|
22442
|
+
return this.firestore.getDoc(this.collectionPath, id);
|
|
22443
|
+
}
|
|
22444
|
+
/**
|
|
22445
|
+
* Obtiene todos los documentos de la colección.
|
|
22446
|
+
*/
|
|
22447
|
+
async getAll(options) {
|
|
22448
|
+
const queryOptions = this.applyDefaultFilters(options);
|
|
22449
|
+
return this.firestore.getDocs(this.collectionPath, queryOptions);
|
|
22450
|
+
}
|
|
22451
|
+
/**
|
|
22452
|
+
* Ejecuta una query personalizada.
|
|
22453
|
+
*/
|
|
22454
|
+
async query(options) {
|
|
22455
|
+
const queryOptions = this.applyDefaultFilters(options);
|
|
22456
|
+
return this.firestore.getDocs(this.collectionPath, queryOptions);
|
|
22457
|
+
}
|
|
22458
|
+
/**
|
|
22459
|
+
* Obtiene documentos con paginación.
|
|
22460
|
+
*/
|
|
22461
|
+
async paginate(options) {
|
|
22462
|
+
const queryOptions = this.applyDefaultFilters(options);
|
|
22463
|
+
return this.firestore.getPaginated(this.collectionPath, queryOptions);
|
|
22464
|
+
}
|
|
22465
|
+
/**
|
|
22466
|
+
* Obtiene el primer documento que coincida con la query.
|
|
22467
|
+
*/
|
|
22468
|
+
async getFirst(options) {
|
|
22469
|
+
const queryOptions = this.applyDefaultFilters({
|
|
22470
|
+
...options,
|
|
22471
|
+
limit: 1,
|
|
22472
|
+
});
|
|
22473
|
+
const results = await this.firestore.getDocs(this.collectionPath, queryOptions);
|
|
22474
|
+
return results[0] ?? null;
|
|
22475
|
+
}
|
|
22476
|
+
/**
|
|
22477
|
+
* Cuenta los documentos que coinciden con la query.
|
|
22478
|
+
* Nota: Esto carga todos los documentos, usar con cuidado en colecciones grandes.
|
|
22479
|
+
*/
|
|
22480
|
+
async count(options) {
|
|
22481
|
+
const queryOptions = this.applyDefaultFilters(options);
|
|
22482
|
+
const results = await this.firestore.getDocs(this.collectionPath, queryOptions);
|
|
22483
|
+
return results.length;
|
|
22484
|
+
}
|
|
22485
|
+
/**
|
|
22486
|
+
* Verifica si un documento existe.
|
|
22487
|
+
*/
|
|
22488
|
+
async exists(id) {
|
|
22489
|
+
return this.firestore.exists(this.collectionPath, id);
|
|
22490
|
+
}
|
|
22491
|
+
// ===========================================================================
|
|
22492
|
+
// SUBSCRIPCIONES REAL-TIME
|
|
22493
|
+
// ===========================================================================
|
|
22494
|
+
/**
|
|
22495
|
+
* Suscribe a cambios de un documento.
|
|
22496
|
+
*/
|
|
22497
|
+
watch(id) {
|
|
22498
|
+
return this.firestore.docChanges(this.collectionPath, id);
|
|
22499
|
+
}
|
|
22500
|
+
/**
|
|
22501
|
+
* Suscribe a cambios de la colección.
|
|
22502
|
+
*/
|
|
22503
|
+
watchAll(options) {
|
|
22504
|
+
const queryOptions = this.applyDefaultFilters(options);
|
|
22505
|
+
return this.firestore.collectionChanges(this.collectionPath, queryOptions);
|
|
22506
|
+
}
|
|
22507
|
+
/**
|
|
22508
|
+
* Suscribe a una query personalizada.
|
|
22509
|
+
*/
|
|
22510
|
+
watchQuery(options) {
|
|
22511
|
+
const queryOptions = this.applyDefaultFilters(options);
|
|
22512
|
+
return this.firestore.collectionChanges(this.collectionPath, queryOptions);
|
|
22513
|
+
}
|
|
22514
|
+
// ===========================================================================
|
|
22515
|
+
// ESCRITURA
|
|
22516
|
+
// ===========================================================================
|
|
22517
|
+
/**
|
|
22518
|
+
* Crea un nuevo documento con ID auto-generado.
|
|
22519
|
+
*/
|
|
22520
|
+
async create(data) {
|
|
22521
|
+
return this.firestore.addDoc(this.collectionPath, data);
|
|
22522
|
+
}
|
|
22523
|
+
/**
|
|
22524
|
+
* Crea un documento con ID específico.
|
|
22525
|
+
*/
|
|
22526
|
+
async createWithId(id, data) {
|
|
22527
|
+
return this.firestore.setDoc(this.collectionPath, id, data);
|
|
22528
|
+
}
|
|
22529
|
+
/**
|
|
22530
|
+
* Actualiza campos de un documento.
|
|
22531
|
+
*/
|
|
22532
|
+
async update(id, data) {
|
|
22533
|
+
return this.firestore.updateDoc(this.collectionPath, id, data);
|
|
22534
|
+
}
|
|
22535
|
+
/**
|
|
22536
|
+
* Elimina un documento.
|
|
22537
|
+
* Si softDelete está habilitado, marca como eliminado en lugar de borrar.
|
|
22538
|
+
*/
|
|
22539
|
+
async delete(id) {
|
|
22540
|
+
if (this.options.softDelete) {
|
|
22541
|
+
return this.firestore.updateDoc(this.collectionPath, id, {
|
|
22542
|
+
deletedAt: new Date(),
|
|
22543
|
+
});
|
|
22544
|
+
}
|
|
22545
|
+
return this.firestore.deleteDoc(this.collectionPath, id);
|
|
22546
|
+
}
|
|
22547
|
+
/**
|
|
22548
|
+
* Restaura un documento soft-deleted.
|
|
22549
|
+
*/
|
|
22550
|
+
async restore(id) {
|
|
22551
|
+
if (!this.options.softDelete) {
|
|
22552
|
+
throw new Error('Soft delete no está habilitado para esta colección');
|
|
22553
|
+
}
|
|
22554
|
+
return this.firestore.updateDoc(this.collectionPath, id, {
|
|
22555
|
+
deletedAt: null,
|
|
22556
|
+
});
|
|
22557
|
+
}
|
|
22558
|
+
// ===========================================================================
|
|
22559
|
+
// SUB-COLECCIONES
|
|
22560
|
+
// ===========================================================================
|
|
22561
|
+
/**
|
|
22562
|
+
* Obtiene una referencia a una sub-colección.
|
|
22563
|
+
*
|
|
22564
|
+
* @example
|
|
22565
|
+
* ```typescript
|
|
22566
|
+
* // En UsersService
|
|
22567
|
+
* getUserDocuments(userId: string) {
|
|
22568
|
+
* return this.users.subcollection<Document>(userId, 'documents');
|
|
22569
|
+
* }
|
|
22570
|
+
*
|
|
22571
|
+
* // Uso
|
|
22572
|
+
* const docs = await users.getUserDocuments('user123').getAll();
|
|
22573
|
+
* ```
|
|
22574
|
+
*/
|
|
22575
|
+
subcollection(parentId, subcollectionName) {
|
|
22576
|
+
const subPath = `${this.collectionPath}/${parentId}/${subcollectionName}`;
|
|
22577
|
+
return {
|
|
22578
|
+
getById: (id) => this.firestore.getDoc(subPath, id),
|
|
22579
|
+
getAll: (options) => this.firestore.getDocs(subPath, options),
|
|
22580
|
+
watch: (id) => this.firestore.docChanges(subPath, id),
|
|
22581
|
+
watchAll: (options) => this.firestore.collectionChanges(subPath, options),
|
|
22582
|
+
create: (data) => this.firestore.addDoc(subPath, data),
|
|
22583
|
+
update: (id, data) => this.firestore.updateDoc(subPath, id, data),
|
|
22584
|
+
delete: (id) => this.firestore.deleteDoc(subPath, id),
|
|
22585
|
+
};
|
|
22586
|
+
}
|
|
22587
|
+
// ===========================================================================
|
|
22588
|
+
// MÉTODOS PRIVADOS
|
|
22589
|
+
// ===========================================================================
|
|
22590
|
+
/**
|
|
22591
|
+
* Aplica filtros por defecto a las queries.
|
|
22592
|
+
*/
|
|
22593
|
+
applyDefaultFilters(options) {
|
|
22594
|
+
if (!this.options.softDelete) {
|
|
22595
|
+
return options ?? {};
|
|
22596
|
+
}
|
|
22597
|
+
// Excluir documentos soft-deleted por defecto
|
|
22598
|
+
const whereClause = { field: 'deletedAt', operator: '==', value: null };
|
|
22599
|
+
return {
|
|
22600
|
+
...options,
|
|
22601
|
+
where: [...(options?.where ?? []), whereClause],
|
|
22602
|
+
};
|
|
22603
|
+
}
|
|
22604
|
+
// ===========================================================================
|
|
22605
|
+
// UTILIDADES
|
|
22606
|
+
// ===========================================================================
|
|
22607
|
+
/**
|
|
22608
|
+
* Genera un nuevo ID sin crear el documento.
|
|
22609
|
+
*/
|
|
22610
|
+
generateId() {
|
|
22611
|
+
return this.firestore.generateId(this.collectionPath);
|
|
22612
|
+
}
|
|
22613
|
+
/**
|
|
22614
|
+
* Obtiene la ruta de la colección.
|
|
22615
|
+
*/
|
|
22616
|
+
getPath() {
|
|
22617
|
+
return this.collectionPath;
|
|
22618
|
+
}
|
|
22619
|
+
}
|
|
22620
|
+
|
|
22621
|
+
/**
|
|
22622
|
+
* Query Builder
|
|
22623
|
+
*
|
|
22624
|
+
* Builder fluido para construir queries de Firestore de manera legible.
|
|
22625
|
+
* Alternativa más expresiva a pasar objetos QueryOptions directamente.
|
|
22626
|
+
*/
|
|
22627
|
+
/**
|
|
22628
|
+
* Builder fluido para queries de Firestore.
|
|
22629
|
+
*
|
|
22630
|
+
* @example
|
|
22631
|
+
* ```typescript
|
|
22632
|
+
* // Construir query con builder
|
|
22633
|
+
* const options = new QueryBuilder()
|
|
22634
|
+
* .where('status', '==', 'active')
|
|
22635
|
+
* .where('age', '>=', 18)
|
|
22636
|
+
* .orderBy('createdAt', 'desc')
|
|
22637
|
+
* .limit(10)
|
|
22638
|
+
* .build();
|
|
22639
|
+
*
|
|
22640
|
+
* // Usar con FirestoreService
|
|
22641
|
+
* const users = await firestoreService.getDocs<User>('users', options);
|
|
22642
|
+
*
|
|
22643
|
+
* // O con método estático
|
|
22644
|
+
* const options2 = QueryBuilder.create()
|
|
22645
|
+
* .where('category', '==', 'electronics')
|
|
22646
|
+
* .orderBy('price', 'asc')
|
|
22647
|
+
* .build();
|
|
22648
|
+
* ```
|
|
22649
|
+
*/
|
|
22650
|
+
class QueryBuilder {
|
|
22651
|
+
constructor() {
|
|
22652
|
+
this.whereConditions = [];
|
|
22653
|
+
this.orderByConditions = [];
|
|
22654
|
+
}
|
|
22655
|
+
/**
|
|
22656
|
+
* Crea una nueva instancia del builder (método estático alternativo).
|
|
22657
|
+
*/
|
|
22658
|
+
static create() {
|
|
22659
|
+
return new QueryBuilder();
|
|
22660
|
+
}
|
|
22661
|
+
/**
|
|
22662
|
+
* Agrega una condición where.
|
|
22663
|
+
*
|
|
22664
|
+
* @param field - Campo a filtrar
|
|
22665
|
+
* @param operator - Operador de comparación
|
|
22666
|
+
* @param value - Valor a comparar
|
|
22667
|
+
*
|
|
22668
|
+
* @example
|
|
22669
|
+
* ```typescript
|
|
22670
|
+
* builder.where('status', '==', 'active')
|
|
22671
|
+
* builder.where('price', '>=', 100)
|
|
22672
|
+
* builder.where('tags', 'array-contains', 'featured')
|
|
22673
|
+
* builder.where('category', 'in', ['electronics', 'books'])
|
|
22674
|
+
* ```
|
|
22675
|
+
*/
|
|
22676
|
+
where(field, operator, value) {
|
|
22677
|
+
this.whereConditions.push({ field, operator, value });
|
|
22678
|
+
return this;
|
|
22679
|
+
}
|
|
22680
|
+
/**
|
|
22681
|
+
* Shortcut para where con operador '=='.
|
|
22682
|
+
*
|
|
22683
|
+
* @example
|
|
22684
|
+
* ```typescript
|
|
22685
|
+
* builder.whereEquals('status', 'active')
|
|
22686
|
+
* // equivalente a: builder.where('status', '==', 'active')
|
|
22687
|
+
* ```
|
|
22688
|
+
*/
|
|
22689
|
+
whereEquals(field, value) {
|
|
22690
|
+
return this.where(field, '==', value);
|
|
22691
|
+
}
|
|
22692
|
+
/**
|
|
22693
|
+
* Shortcut para where con operador '!='.
|
|
22694
|
+
*/
|
|
22695
|
+
whereNotEquals(field, value) {
|
|
22696
|
+
return this.where(field, '!=', value);
|
|
22697
|
+
}
|
|
22698
|
+
/**
|
|
22699
|
+
* Shortcut para where con operador '>'.
|
|
22700
|
+
*/
|
|
22701
|
+
whereGreaterThan(field, value) {
|
|
22702
|
+
return this.where(field, '>', value);
|
|
22703
|
+
}
|
|
22704
|
+
/**
|
|
22705
|
+
* Shortcut para where con operador '>='.
|
|
22706
|
+
*/
|
|
22707
|
+
whereGreaterOrEqual(field, value) {
|
|
22708
|
+
return this.where(field, '>=', value);
|
|
22709
|
+
}
|
|
22710
|
+
/**
|
|
22711
|
+
* Shortcut para where con operador '<'.
|
|
22712
|
+
*/
|
|
22713
|
+
whereLessThan(field, value) {
|
|
22714
|
+
return this.where(field, '<', value);
|
|
22715
|
+
}
|
|
22716
|
+
/**
|
|
22717
|
+
* Shortcut para where con operador '<='.
|
|
22718
|
+
*/
|
|
22719
|
+
whereLessOrEqual(field, value) {
|
|
22720
|
+
return this.where(field, '<=', value);
|
|
22721
|
+
}
|
|
22722
|
+
/**
|
|
22723
|
+
* Shortcut para where con operador 'array-contains'.
|
|
22724
|
+
*
|
|
22725
|
+
* @example
|
|
22726
|
+
* ```typescript
|
|
22727
|
+
* builder.whereArrayContains('tags', 'featured')
|
|
22728
|
+
* ```
|
|
22729
|
+
*/
|
|
22730
|
+
whereArrayContains(field, value) {
|
|
22731
|
+
return this.where(field, 'array-contains', value);
|
|
22732
|
+
}
|
|
22733
|
+
/**
|
|
22734
|
+
* Shortcut para where con operador 'array-contains-any'.
|
|
22735
|
+
*
|
|
22736
|
+
* @example
|
|
22737
|
+
* ```typescript
|
|
22738
|
+
* builder.whereArrayContainsAny('tags', ['featured', 'new'])
|
|
22739
|
+
* ```
|
|
22740
|
+
*/
|
|
22741
|
+
whereArrayContainsAny(field, values) {
|
|
22742
|
+
return this.where(field, 'array-contains-any', values);
|
|
22743
|
+
}
|
|
22744
|
+
/**
|
|
22745
|
+
* Shortcut para where con operador 'in'.
|
|
22746
|
+
*
|
|
22747
|
+
* @example
|
|
22748
|
+
* ```typescript
|
|
22749
|
+
* builder.whereIn('status', ['active', 'pending'])
|
|
22750
|
+
* ```
|
|
22751
|
+
*/
|
|
22752
|
+
whereIn(field, values) {
|
|
22753
|
+
return this.where(field, 'in', values);
|
|
22754
|
+
}
|
|
22755
|
+
/**
|
|
22756
|
+
* Shortcut para where con operador 'not-in'.
|
|
22757
|
+
*/
|
|
22758
|
+
whereNotIn(field, values) {
|
|
22759
|
+
return this.where(field, 'not-in', values);
|
|
22760
|
+
}
|
|
22761
|
+
/**
|
|
22762
|
+
* Agrega ordenamiento por un campo.
|
|
22763
|
+
*
|
|
22764
|
+
* @param field - Campo por el cual ordenar
|
|
22765
|
+
* @param direction - Dirección: 'asc' o 'desc' (default: 'asc')
|
|
22766
|
+
*
|
|
22767
|
+
* @example
|
|
22768
|
+
* ```typescript
|
|
22769
|
+
* builder.orderBy('createdAt', 'desc')
|
|
22770
|
+
* builder.orderBy('name') // asc por defecto
|
|
22771
|
+
* ```
|
|
22772
|
+
*/
|
|
22773
|
+
orderBy(field, direction = 'asc') {
|
|
22774
|
+
this.orderByConditions.push({ field, direction });
|
|
22775
|
+
return this;
|
|
22776
|
+
}
|
|
22777
|
+
/**
|
|
22778
|
+
* Shortcut para orderBy descendente.
|
|
22779
|
+
*/
|
|
22780
|
+
orderByDesc(field) {
|
|
22781
|
+
return this.orderBy(field, 'desc');
|
|
22782
|
+
}
|
|
22783
|
+
/**
|
|
22784
|
+
* Shortcut para orderBy ascendente.
|
|
22785
|
+
*/
|
|
22786
|
+
orderByAsc(field) {
|
|
22787
|
+
return this.orderBy(field, 'asc');
|
|
22788
|
+
}
|
|
22789
|
+
/**
|
|
22790
|
+
* Limita el número de resultados.
|
|
22791
|
+
*
|
|
22792
|
+
* @param count - Número máximo de documentos
|
|
22793
|
+
*
|
|
22794
|
+
* @example
|
|
22795
|
+
* ```typescript
|
|
22796
|
+
* builder.limit(10)
|
|
22797
|
+
* ```
|
|
22798
|
+
*/
|
|
22799
|
+
limit(count) {
|
|
22800
|
+
if (count <= 0) {
|
|
22801
|
+
throw new Error('El límite debe ser mayor a 0');
|
|
22802
|
+
}
|
|
22803
|
+
this.limitValue = count;
|
|
22804
|
+
return this;
|
|
22805
|
+
}
|
|
22806
|
+
/**
|
|
22807
|
+
* Cursor para paginación: empezar después de un documento.
|
|
22808
|
+
*
|
|
22809
|
+
* @param cursor - Documento o snapshot desde donde continuar
|
|
22810
|
+
*
|
|
22811
|
+
* @example
|
|
22812
|
+
* ```typescript
|
|
22813
|
+
* // Primera página
|
|
22814
|
+
* const page1 = await service.getPaginated('users', builder.limit(10).build());
|
|
22815
|
+
*
|
|
22816
|
+
* // Siguiente página
|
|
22817
|
+
* const page2 = await service.getPaginated('users',
|
|
22818
|
+
* builder.startAfter(page1.lastDoc).limit(10).build()
|
|
22819
|
+
* );
|
|
22820
|
+
* ```
|
|
22821
|
+
*/
|
|
22822
|
+
startAfter(cursor) {
|
|
22823
|
+
this.startAfterValue = cursor;
|
|
22824
|
+
return this;
|
|
22825
|
+
}
|
|
22826
|
+
/**
|
|
22827
|
+
* Cursor para paginación: empezar en un documento.
|
|
22828
|
+
*/
|
|
22829
|
+
startAt(cursor) {
|
|
22830
|
+
this.startAtValue = cursor;
|
|
22831
|
+
return this;
|
|
22832
|
+
}
|
|
22833
|
+
/**
|
|
22834
|
+
* Cursor para paginación: terminar antes de un documento.
|
|
22835
|
+
*/
|
|
22836
|
+
endBefore(cursor) {
|
|
22837
|
+
this.endBeforeValue = cursor;
|
|
22838
|
+
return this;
|
|
22839
|
+
}
|
|
22840
|
+
/**
|
|
22841
|
+
* Cursor para paginación: terminar en un documento.
|
|
22842
|
+
*/
|
|
22843
|
+
endAt(cursor) {
|
|
22844
|
+
this.endAtValue = cursor;
|
|
22845
|
+
return this;
|
|
22846
|
+
}
|
|
22847
|
+
/**
|
|
22848
|
+
* Construye el objeto QueryOptions.
|
|
22849
|
+
*
|
|
22850
|
+
* @returns QueryOptions para usar con FirestoreService
|
|
22851
|
+
*/
|
|
22852
|
+
build() {
|
|
22853
|
+
const options = {};
|
|
22854
|
+
if (this.whereConditions.length > 0) {
|
|
22855
|
+
options.where = [...this.whereConditions];
|
|
22856
|
+
}
|
|
22857
|
+
if (this.orderByConditions.length > 0) {
|
|
22858
|
+
options.orderBy = [...this.orderByConditions];
|
|
22859
|
+
}
|
|
22860
|
+
if (this.limitValue !== undefined) {
|
|
22861
|
+
options.limit = this.limitValue;
|
|
22862
|
+
}
|
|
22863
|
+
if (this.startAfterValue !== undefined) {
|
|
22864
|
+
options.startAfter = this.startAfterValue;
|
|
22865
|
+
}
|
|
22866
|
+
if (this.startAtValue !== undefined) {
|
|
22867
|
+
options.startAt = this.startAtValue;
|
|
22868
|
+
}
|
|
22869
|
+
if (this.endBeforeValue !== undefined) {
|
|
22870
|
+
options.endBefore = this.endBeforeValue;
|
|
22871
|
+
}
|
|
22872
|
+
if (this.endAtValue !== undefined) {
|
|
22873
|
+
options.endAt = this.endAtValue;
|
|
22874
|
+
}
|
|
22875
|
+
return options;
|
|
22876
|
+
}
|
|
22877
|
+
/**
|
|
22878
|
+
* Resetea el builder para reutilización.
|
|
22879
|
+
*/
|
|
22880
|
+
reset() {
|
|
22881
|
+
this.whereConditions = [];
|
|
22882
|
+
this.orderByConditions = [];
|
|
22883
|
+
this.limitValue = undefined;
|
|
22884
|
+
this.startAfterValue = undefined;
|
|
22885
|
+
this.startAtValue = undefined;
|
|
22886
|
+
this.endBeforeValue = undefined;
|
|
22887
|
+
this.endAtValue = undefined;
|
|
22888
|
+
return this;
|
|
22889
|
+
}
|
|
22890
|
+
/**
|
|
22891
|
+
* Clona el builder actual.
|
|
22892
|
+
*/
|
|
22893
|
+
clone() {
|
|
22894
|
+
const cloned = new QueryBuilder();
|
|
22895
|
+
cloned.whereConditions = [...this.whereConditions];
|
|
22896
|
+
cloned.orderByConditions = [...this.orderByConditions];
|
|
22897
|
+
cloned.limitValue = this.limitValue;
|
|
22898
|
+
cloned.startAfterValue = this.startAfterValue;
|
|
22899
|
+
cloned.startAtValue = this.startAtValue;
|
|
22900
|
+
cloned.endBeforeValue = this.endBeforeValue;
|
|
22901
|
+
cloned.endAtValue = this.endAtValue;
|
|
22902
|
+
return cloned;
|
|
22903
|
+
}
|
|
22904
|
+
}
|
|
22905
|
+
/**
|
|
22906
|
+
* Función helper para crear un QueryBuilder.
|
|
22907
|
+
*
|
|
22908
|
+
* @example
|
|
22909
|
+
* ```typescript
|
|
22910
|
+
* import { query } from 'valtech-components';
|
|
22911
|
+
*
|
|
22912
|
+
* const options = query()
|
|
22913
|
+
* .where('status', '==', 'active')
|
|
22914
|
+
* .orderBy('createdAt', 'desc')
|
|
22915
|
+
* .limit(10)
|
|
22916
|
+
* .build();
|
|
22917
|
+
* ```
|
|
22918
|
+
*/
|
|
22919
|
+
function query() {
|
|
22920
|
+
return new QueryBuilder();
|
|
22921
|
+
}
|
|
22922
|
+
|
|
22923
|
+
/**
|
|
22924
|
+
* Storage Service
|
|
22925
|
+
*
|
|
22926
|
+
* Servicio para operaciones de Firebase Storage.
|
|
22927
|
+
* Soporta upload con tracking de progreso, download y gestión de archivos.
|
|
22928
|
+
*/
|
|
22929
|
+
/**
|
|
22930
|
+
* Servicio para Firebase Storage.
|
|
22931
|
+
*
|
|
22932
|
+
* @example
|
|
22933
|
+
* ```typescript
|
|
22934
|
+
* @Component({...})
|
|
22935
|
+
* export class FileUploadComponent {
|
|
22936
|
+
* private storage = inject(StorageService);
|
|
22937
|
+
*
|
|
22938
|
+
* uploadProgress = signal<number>(0);
|
|
22939
|
+
* downloadUrl = signal<string | null>(null);
|
|
22940
|
+
*
|
|
22941
|
+
* async onFileSelected(event: Event) {
|
|
22942
|
+
* const file = (event.target as HTMLInputElement).files?.[0];
|
|
22943
|
+
* if (!file) return;
|
|
22944
|
+
*
|
|
22945
|
+
* // Upload con progreso
|
|
22946
|
+
* this.storage.upload(`uploads/${file.name}`, file).subscribe({
|
|
22947
|
+
* next: (progress) => this.uploadProgress.set(progress.percentage),
|
|
22948
|
+
* complete: async () => {
|
|
22949
|
+
* const url = await this.storage.getDownloadUrl(`uploads/${file.name}`);
|
|
22950
|
+
* this.downloadUrl.set(url);
|
|
22951
|
+
* }
|
|
22952
|
+
* });
|
|
22953
|
+
* }
|
|
22954
|
+
* }
|
|
22955
|
+
* ```
|
|
22956
|
+
*/
|
|
22957
|
+
class StorageService {
|
|
22958
|
+
constructor(storage) {
|
|
22959
|
+
this.storage = storage;
|
|
22960
|
+
}
|
|
22961
|
+
// ===========================================================================
|
|
22962
|
+
// UPLOAD
|
|
22963
|
+
// ===========================================================================
|
|
22964
|
+
/**
|
|
22965
|
+
* Sube un archivo con tracking de progreso.
|
|
22966
|
+
*
|
|
22967
|
+
* @param path - Ruta en Storage donde guardar el archivo
|
|
22968
|
+
* @param file - Archivo a subir (File o Blob)
|
|
22969
|
+
* @param metadata - Metadata opcional (contentType, customMetadata)
|
|
22970
|
+
* @returns Observable que emite el progreso y completa cuando termina
|
|
22971
|
+
*
|
|
22972
|
+
* @example
|
|
22973
|
+
* ```typescript
|
|
22974
|
+
* // Upload básico
|
|
22975
|
+
* storage.upload('images/photo.jpg', file).subscribe({
|
|
22976
|
+
* next: (progress) => console.log(`${progress.percentage}%`),
|
|
22977
|
+
* complete: () => console.log('Upload completado')
|
|
22978
|
+
* });
|
|
22979
|
+
*
|
|
22980
|
+
* // Con metadata
|
|
22981
|
+
* storage.upload('docs/report.pdf', file, {
|
|
22982
|
+
* contentType: 'application/pdf',
|
|
22983
|
+
* customMetadata: { uploadedBy: 'user123' }
|
|
22984
|
+
* }).subscribe(...);
|
|
22985
|
+
* ```
|
|
22986
|
+
*/
|
|
22987
|
+
upload(path, file, metadata) {
|
|
22988
|
+
const storageRef = ref(this.storage, path);
|
|
22989
|
+
const uploadMetadata = {
|
|
22990
|
+
contentType: metadata?.contentType || (file instanceof File ? file.type : undefined),
|
|
22991
|
+
customMetadata: metadata?.customMetadata,
|
|
22992
|
+
cacheControl: metadata?.cacheControl,
|
|
22993
|
+
};
|
|
22994
|
+
const task = uploadBytesResumable(storageRef, file, uploadMetadata);
|
|
22995
|
+
const progress$ = new BehaviorSubject({
|
|
22996
|
+
bytesTransferred: 0,
|
|
22997
|
+
totalBytes: file.size,
|
|
22998
|
+
percentage: 0,
|
|
22999
|
+
state: 'running',
|
|
23000
|
+
});
|
|
23001
|
+
task.on('state_changed', (snapshot) => {
|
|
23002
|
+
progress$.next({
|
|
23003
|
+
bytesTransferred: snapshot.bytesTransferred,
|
|
23004
|
+
totalBytes: snapshot.totalBytes,
|
|
23005
|
+
percentage: Math.round((snapshot.bytesTransferred / snapshot.totalBytes) * 100),
|
|
23006
|
+
state: this.mapTaskState(snapshot.state),
|
|
23007
|
+
});
|
|
23008
|
+
}, (error) => {
|
|
23009
|
+
progress$.next({
|
|
23010
|
+
bytesTransferred: 0,
|
|
23011
|
+
totalBytes: file.size,
|
|
23012
|
+
percentage: 0,
|
|
23013
|
+
state: 'error',
|
|
23014
|
+
});
|
|
23015
|
+
progress$.error(this.getErrorMessage(error));
|
|
23016
|
+
}, () => {
|
|
23017
|
+
progress$.next({
|
|
23018
|
+
bytesTransferred: file.size,
|
|
23019
|
+
totalBytes: file.size,
|
|
23020
|
+
percentage: 100,
|
|
23021
|
+
state: 'success',
|
|
23022
|
+
});
|
|
23023
|
+
progress$.complete();
|
|
23024
|
+
});
|
|
23025
|
+
return progress$.asObservable();
|
|
23026
|
+
}
|
|
23027
|
+
/**
|
|
23028
|
+
* Sube un archivo y retorna la URL de descarga al completar.
|
|
23029
|
+
*
|
|
23030
|
+
* @param path - Ruta en Storage
|
|
23031
|
+
* @param file - Archivo a subir
|
|
23032
|
+
* @param metadata - Metadata opcional
|
|
23033
|
+
* @returns Resultado del upload con URL de descarga
|
|
23034
|
+
*
|
|
23035
|
+
* @example
|
|
23036
|
+
* ```typescript
|
|
23037
|
+
* const result = await storage.uploadAndGetUrl('avatars/user123.jpg', file);
|
|
23038
|
+
* console.log('URL:', result.downloadUrl);
|
|
23039
|
+
* ```
|
|
23040
|
+
*/
|
|
23041
|
+
async uploadAndGetUrl(path, file, metadata) {
|
|
23042
|
+
return new Promise((resolve, reject) => {
|
|
23043
|
+
this.upload(path, file, metadata).subscribe({
|
|
23044
|
+
complete: async () => {
|
|
23045
|
+
try {
|
|
23046
|
+
const storageRef = ref(this.storage, path);
|
|
23047
|
+
const downloadUrl = await getDownloadURL(storageRef);
|
|
23048
|
+
const storedMetadata = await getMetadata(storageRef);
|
|
23049
|
+
resolve({
|
|
23050
|
+
downloadUrl,
|
|
23051
|
+
fullPath: storedMetadata.fullPath,
|
|
23052
|
+
name: storedMetadata.name,
|
|
23053
|
+
size: storedMetadata.size,
|
|
23054
|
+
contentType: storedMetadata.contentType || 'application/octet-stream',
|
|
23055
|
+
metadata: storedMetadata.customMetadata || {},
|
|
23056
|
+
});
|
|
23057
|
+
}
|
|
23058
|
+
catch (error) {
|
|
23059
|
+
reject(this.getErrorMessage(error));
|
|
23060
|
+
}
|
|
23061
|
+
},
|
|
23062
|
+
error: (error) => reject(error),
|
|
23063
|
+
});
|
|
23064
|
+
});
|
|
23065
|
+
}
|
|
23066
|
+
/**
|
|
23067
|
+
* Sube un archivo desde una Data URL (base64).
|
|
23068
|
+
*
|
|
23069
|
+
* @param path - Ruta en Storage
|
|
23070
|
+
* @param dataUrl - Data URL (ej: 'data:image/png;base64,...')
|
|
23071
|
+
* @param metadata - Metadata opcional
|
|
23072
|
+
* @returns Resultado del upload
|
|
23073
|
+
*
|
|
23074
|
+
* @example
|
|
23075
|
+
* ```typescript
|
|
23076
|
+
* // Desde canvas
|
|
23077
|
+
* const dataUrl = canvas.toDataURL('image/png');
|
|
23078
|
+
* const result = await storage.uploadFromDataUrl('images/drawing.png', dataUrl);
|
|
23079
|
+
* ```
|
|
23080
|
+
*/
|
|
23081
|
+
async uploadFromDataUrl(path, dataUrl, metadata) {
|
|
23082
|
+
// Extraer content type y datos base64
|
|
23083
|
+
const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
|
23084
|
+
if (!matches) {
|
|
23085
|
+
throw new Error('Data URL inválida');
|
|
23086
|
+
}
|
|
23087
|
+
const contentType = matches[1];
|
|
23088
|
+
const base64Data = matches[2];
|
|
23089
|
+
// Convertir base64 a Blob
|
|
23090
|
+
const byteCharacters = atob(base64Data);
|
|
23091
|
+
const byteNumbers = new Array(byteCharacters.length);
|
|
23092
|
+
for (let i = 0; i < byteCharacters.length; i++) {
|
|
23093
|
+
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
23094
|
+
}
|
|
23095
|
+
const byteArray = new Uint8Array(byteNumbers);
|
|
23096
|
+
const blob = new Blob([byteArray], { type: contentType });
|
|
23097
|
+
return this.uploadAndGetUrl(path, blob, {
|
|
23098
|
+
contentType,
|
|
23099
|
+
...metadata,
|
|
23100
|
+
});
|
|
23101
|
+
}
|
|
23102
|
+
// ===========================================================================
|
|
23103
|
+
// DOWNLOAD
|
|
23104
|
+
// ===========================================================================
|
|
23105
|
+
/**
|
|
23106
|
+
* Obtiene la URL de descarga de un archivo.
|
|
23107
|
+
*
|
|
23108
|
+
* @param path - Ruta del archivo en Storage
|
|
23109
|
+
* @returns URL de descarga
|
|
23110
|
+
*
|
|
23111
|
+
* @example
|
|
23112
|
+
* ```typescript
|
|
23113
|
+
* const url = await storage.getDownloadUrl('images/photo.jpg');
|
|
23114
|
+
* // Usar en <img [src]="url">
|
|
23115
|
+
* ```
|
|
23116
|
+
*/
|
|
23117
|
+
async getDownloadUrl(path) {
|
|
23118
|
+
try {
|
|
23119
|
+
const storageRef = ref(this.storage, path);
|
|
23120
|
+
return await getDownloadURL(storageRef);
|
|
23121
|
+
}
|
|
23122
|
+
catch (error) {
|
|
23123
|
+
throw new Error(this.getErrorMessage(error));
|
|
23124
|
+
}
|
|
23125
|
+
}
|
|
23126
|
+
/**
|
|
23127
|
+
* Obtiene la metadata de un archivo.
|
|
23128
|
+
*
|
|
23129
|
+
* @param path - Ruta del archivo
|
|
23130
|
+
* @returns Metadata del archivo
|
|
23131
|
+
*/
|
|
23132
|
+
async getMetadata(path) {
|
|
23133
|
+
try {
|
|
23134
|
+
const storageRef = ref(this.storage, path);
|
|
23135
|
+
const metadata = await getMetadata(storageRef);
|
|
23136
|
+
return {
|
|
23137
|
+
contentType: metadata.contentType,
|
|
23138
|
+
customMetadata: metadata.customMetadata,
|
|
23139
|
+
cacheControl: metadata.cacheControl,
|
|
23140
|
+
size: metadata.size,
|
|
23141
|
+
name: metadata.name,
|
|
23142
|
+
};
|
|
23143
|
+
}
|
|
23144
|
+
catch (error) {
|
|
23145
|
+
throw new Error(this.getErrorMessage(error));
|
|
23146
|
+
}
|
|
23147
|
+
}
|
|
23148
|
+
// ===========================================================================
|
|
23149
|
+
// DELETE
|
|
23150
|
+
// ===========================================================================
|
|
23151
|
+
/**
|
|
23152
|
+
* Elimina un archivo.
|
|
23153
|
+
*
|
|
23154
|
+
* @param path - Ruta del archivo a eliminar
|
|
23155
|
+
*
|
|
23156
|
+
* @example
|
|
23157
|
+
* ```typescript
|
|
23158
|
+
* await storage.delete('images/old-photo.jpg');
|
|
23159
|
+
* ```
|
|
23160
|
+
*/
|
|
23161
|
+
async delete(path) {
|
|
23162
|
+
try {
|
|
23163
|
+
const storageRef = ref(this.storage, path);
|
|
23164
|
+
await deleteObject(storageRef);
|
|
23165
|
+
}
|
|
23166
|
+
catch (error) {
|
|
23167
|
+
throw new Error(this.getErrorMessage(error));
|
|
23168
|
+
}
|
|
23169
|
+
}
|
|
23170
|
+
/**
|
|
23171
|
+
* Elimina múltiples archivos.
|
|
23172
|
+
*
|
|
23173
|
+
* @param paths - Array de rutas a eliminar
|
|
23174
|
+
*
|
|
23175
|
+
* @example
|
|
23176
|
+
* ```typescript
|
|
23177
|
+
* await storage.deleteMultiple([
|
|
23178
|
+
* 'images/photo1.jpg',
|
|
23179
|
+
* 'images/photo2.jpg'
|
|
23180
|
+
* ]);
|
|
23181
|
+
* ```
|
|
23182
|
+
*/
|
|
23183
|
+
async deleteMultiple(paths) {
|
|
23184
|
+
await Promise.all(paths.map((path) => this.delete(path)));
|
|
23185
|
+
}
|
|
23186
|
+
// ===========================================================================
|
|
23187
|
+
// LIST
|
|
23188
|
+
// ===========================================================================
|
|
23189
|
+
/**
|
|
23190
|
+
* Lista archivos en un directorio.
|
|
23191
|
+
*
|
|
23192
|
+
* @param path - Ruta del directorio
|
|
23193
|
+
* @returns Lista de rutas de archivos
|
|
23194
|
+
*
|
|
23195
|
+
* @example
|
|
23196
|
+
* ```typescript
|
|
23197
|
+
* const result = await storage.list('images/');
|
|
23198
|
+
* console.log(result.items); // ['images/photo1.jpg', 'images/photo2.jpg']
|
|
23199
|
+
* ```
|
|
23200
|
+
*/
|
|
23201
|
+
async list(path) {
|
|
23202
|
+
try {
|
|
23203
|
+
const storageRef = ref(this.storage, path);
|
|
23204
|
+
const result = await listAll(storageRef);
|
|
23205
|
+
return {
|
|
23206
|
+
items: result.items.map((item) => item.fullPath),
|
|
23207
|
+
nextPageToken: undefined, // listAll no soporta paginación
|
|
23208
|
+
};
|
|
23209
|
+
}
|
|
23210
|
+
catch (error) {
|
|
23211
|
+
throw new Error(this.getErrorMessage(error));
|
|
23212
|
+
}
|
|
23213
|
+
}
|
|
23214
|
+
// ===========================================================================
|
|
23215
|
+
// UTILIDADES
|
|
23216
|
+
// ===========================================================================
|
|
23217
|
+
/**
|
|
23218
|
+
* Genera un nombre de archivo único con timestamp.
|
|
23219
|
+
*
|
|
23220
|
+
* @param originalName - Nombre original del archivo
|
|
23221
|
+
* @param prefix - Prefijo opcional
|
|
23222
|
+
* @returns Nombre único
|
|
23223
|
+
*
|
|
23224
|
+
* @example
|
|
23225
|
+
* ```typescript
|
|
23226
|
+
* const uniqueName = storage.generateFileName('photo.jpg', 'user123');
|
|
23227
|
+
* // => 'user123_1703091234567_photo.jpg'
|
|
23228
|
+
* ```
|
|
23229
|
+
*/
|
|
23230
|
+
generateFileName(originalName, prefix) {
|
|
23231
|
+
const timestamp = Date.now();
|
|
23232
|
+
const sanitizedName = originalName.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
23233
|
+
if (prefix) {
|
|
23234
|
+
return `${prefix}_${timestamp}_${sanitizedName}`;
|
|
23235
|
+
}
|
|
23236
|
+
return `${timestamp}_${sanitizedName}`;
|
|
23237
|
+
}
|
|
23238
|
+
/**
|
|
23239
|
+
* Genera una ruta única para un archivo.
|
|
23240
|
+
*
|
|
23241
|
+
* @param directory - Directorio base
|
|
23242
|
+
* @param originalName - Nombre original
|
|
23243
|
+
* @param prefix - Prefijo opcional
|
|
23244
|
+
* @returns Ruta completa única
|
|
23245
|
+
*
|
|
23246
|
+
* @example
|
|
23247
|
+
* ```typescript
|
|
23248
|
+
* const path = storage.generatePath('uploads', 'photo.jpg', 'user123');
|
|
23249
|
+
* // => 'uploads/user123_1703091234567_photo.jpg'
|
|
23250
|
+
* ```
|
|
23251
|
+
*/
|
|
23252
|
+
generatePath(directory, originalName, prefix) {
|
|
23253
|
+
const fileName = this.generateFileName(originalName, prefix);
|
|
23254
|
+
const cleanDir = directory.replace(/\/+$/, ''); // Remover / final
|
|
23255
|
+
return `${cleanDir}/${fileName}`;
|
|
23256
|
+
}
|
|
23257
|
+
/**
|
|
23258
|
+
* Obtiene la extensión de un archivo.
|
|
23259
|
+
*
|
|
23260
|
+
* @param filename - Nombre del archivo
|
|
23261
|
+
* @returns Extensión (sin el punto)
|
|
23262
|
+
*/
|
|
23263
|
+
getExtension(filename) {
|
|
23264
|
+
const parts = filename.split('.');
|
|
23265
|
+
return parts.length > 1 ? parts.pop().toLowerCase() : '';
|
|
23266
|
+
}
|
|
23267
|
+
/**
|
|
23268
|
+
* Verifica si un archivo es una imagen basándose en su extensión.
|
|
23269
|
+
*/
|
|
23270
|
+
isImage(filename) {
|
|
23271
|
+
const ext = this.getExtension(filename);
|
|
23272
|
+
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);
|
|
23273
|
+
}
|
|
23274
|
+
/**
|
|
23275
|
+
* Verifica si un archivo es un documento.
|
|
23276
|
+
*/
|
|
23277
|
+
isDocument(filename) {
|
|
23278
|
+
const ext = this.getExtension(filename);
|
|
23279
|
+
return ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'].includes(ext);
|
|
23280
|
+
}
|
|
23281
|
+
// ===========================================================================
|
|
23282
|
+
// MÉTODOS PRIVADOS
|
|
23283
|
+
// ===========================================================================
|
|
23284
|
+
/**
|
|
23285
|
+
* Mapea el estado de la tarea de upload
|
|
23286
|
+
*/
|
|
23287
|
+
mapTaskState(state) {
|
|
23288
|
+
switch (state) {
|
|
23289
|
+
case 'running':
|
|
23290
|
+
return 'running';
|
|
23291
|
+
case 'paused':
|
|
23292
|
+
return 'paused';
|
|
23293
|
+
case 'success':
|
|
23294
|
+
return 'success';
|
|
23295
|
+
case 'canceled':
|
|
23296
|
+
return 'canceled';
|
|
23297
|
+
case 'error':
|
|
23298
|
+
return 'error';
|
|
23299
|
+
default:
|
|
23300
|
+
return 'running';
|
|
23301
|
+
}
|
|
23302
|
+
}
|
|
23303
|
+
/**
|
|
23304
|
+
* Convierte errores de Storage a mensajes en español
|
|
23305
|
+
*/
|
|
23306
|
+
getErrorMessage(error) {
|
|
23307
|
+
if (error instanceof Error) {
|
|
23308
|
+
const code = error.code;
|
|
23309
|
+
switch (code) {
|
|
23310
|
+
case 'storage/object-not-found':
|
|
23311
|
+
return 'El archivo no existe';
|
|
23312
|
+
case 'storage/unauthorized':
|
|
23313
|
+
return 'No tienes permiso para acceder a este archivo';
|
|
23314
|
+
case 'storage/canceled':
|
|
23315
|
+
return 'La operación fue cancelada';
|
|
23316
|
+
case 'storage/quota-exceeded':
|
|
23317
|
+
return 'Se ha excedido la cuota de almacenamiento';
|
|
23318
|
+
case 'storage/invalid-checksum':
|
|
23319
|
+
return 'El archivo está corrupto';
|
|
23320
|
+
case 'storage/retry-limit-exceeded':
|
|
23321
|
+
return 'Error de conexión. Intenta de nuevo';
|
|
23322
|
+
case 'storage/invalid-url':
|
|
23323
|
+
return 'URL de archivo inválida';
|
|
23324
|
+
case 'storage/invalid-argument':
|
|
23325
|
+
return 'Argumento inválido';
|
|
23326
|
+
default:
|
|
23327
|
+
return error.message || 'Error de almacenamiento desconocido';
|
|
23328
|
+
}
|
|
23329
|
+
}
|
|
23330
|
+
return 'Error de almacenamiento desconocido';
|
|
23331
|
+
}
|
|
23332
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: StorageService, deps: [{ token: i1$7.Storage }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
23333
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: StorageService, providedIn: 'root' }); }
|
|
23334
|
+
}
|
|
23335
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: StorageService, decorators: [{
|
|
23336
|
+
type: Injectable,
|
|
23337
|
+
args: [{ providedIn: 'root' }]
|
|
23338
|
+
}], ctorParameters: () => [{ type: i1$7.Storage }] });
|
|
23339
|
+
|
|
23340
|
+
/**
|
|
23341
|
+
* Messaging Service (FCM)
|
|
23342
|
+
*
|
|
23343
|
+
* Servicio para Firebase Cloud Messaging (Push Notifications).
|
|
23344
|
+
* Permite solicitar permisos, obtener tokens, escuchar mensajes y manejar
|
|
23345
|
+
* navegación (deep linking) cuando el usuario toca una notificación.
|
|
23346
|
+
*/
|
|
23347
|
+
/**
|
|
23348
|
+
* Servicio para Firebase Cloud Messaging (FCM).
|
|
23349
|
+
*
|
|
23350
|
+
* Permite recibir notificaciones push en la aplicación web.
|
|
23351
|
+
* Requiere VAPID key configurada en ValtechFirebaseConfig.
|
|
23352
|
+
*
|
|
23353
|
+
* @example
|
|
23354
|
+
* ```typescript
|
|
23355
|
+
* @Component({...})
|
|
23356
|
+
* export class NotificationComponent {
|
|
23357
|
+
* private messaging = inject(MessagingService);
|
|
23358
|
+
*
|
|
23359
|
+
* token = signal<string | null>(null);
|
|
23360
|
+
*
|
|
23361
|
+
* async enableNotifications() {
|
|
23362
|
+
* // Solicitar permiso y obtener token
|
|
23363
|
+
* const token = await this.messaging.requestPermission();
|
|
23364
|
+
*
|
|
23365
|
+
* if (token) {
|
|
23366
|
+
* this.token.set(token);
|
|
23367
|
+
* // Enviar token a tu backend para almacenarlo
|
|
23368
|
+
* await this.backend.registerDeviceToken(token);
|
|
23369
|
+
* }
|
|
23370
|
+
* }
|
|
23371
|
+
*
|
|
23372
|
+
* // Escuchar mensajes en foreground
|
|
23373
|
+
* messages$ = this.messaging.onMessage();
|
|
23374
|
+
* }
|
|
23375
|
+
* ```
|
|
23376
|
+
*/
|
|
23377
|
+
class MessagingService {
|
|
23378
|
+
constructor(messaging, config, platformId, ngZone) {
|
|
23379
|
+
this.messaging = messaging;
|
|
23380
|
+
this.config = config;
|
|
23381
|
+
this.platformId = platformId;
|
|
23382
|
+
this.ngZone = ngZone;
|
|
23383
|
+
this.messageSubject = new Subject();
|
|
23384
|
+
this.notificationClickSubject = new Subject();
|
|
23385
|
+
this.stateSubject = new BehaviorSubject({
|
|
23386
|
+
token: null,
|
|
23387
|
+
permission: 'default',
|
|
23388
|
+
isSupported: false,
|
|
23389
|
+
});
|
|
23390
|
+
this.initializeMessaging();
|
|
23391
|
+
}
|
|
23392
|
+
// ===========================================================================
|
|
23393
|
+
// INICIALIZACIÓN
|
|
23394
|
+
// ===========================================================================
|
|
23395
|
+
/**
|
|
23396
|
+
* Inicializa el servicio de messaging
|
|
23397
|
+
*/
|
|
23398
|
+
async initializeMessaging() {
|
|
23399
|
+
if (!isPlatformBrowser(this.platformId)) {
|
|
23400
|
+
return;
|
|
23401
|
+
}
|
|
23402
|
+
const supported = await this.checkSupport();
|
|
23403
|
+
const permission = this.getPermissionState();
|
|
23404
|
+
this.stateSubject.next({
|
|
23405
|
+
...this.stateSubject.value,
|
|
23406
|
+
isSupported: supported,
|
|
23407
|
+
permission,
|
|
23408
|
+
});
|
|
23409
|
+
// Si ya tiene permiso, configurar listeners
|
|
23410
|
+
if (supported && permission === 'granted') {
|
|
23411
|
+
this.setupMessageListener();
|
|
23412
|
+
}
|
|
23413
|
+
// Escuchar mensajes del Service Worker (clicks en notificaciones background)
|
|
23414
|
+
this.setupServiceWorkerListener();
|
|
23415
|
+
}
|
|
23416
|
+
/**
|
|
23417
|
+
* Configura listener para mensajes del Service Worker.
|
|
23418
|
+
* Recibe eventos cuando el usuario hace click en una notificación background.
|
|
23419
|
+
*/
|
|
23420
|
+
setupServiceWorkerListener() {
|
|
23421
|
+
if (!isPlatformBrowser(this.platformId) || !('serviceWorker' in navigator)) {
|
|
23422
|
+
return;
|
|
23423
|
+
}
|
|
23424
|
+
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
23425
|
+
// Verificar que es un mensaje de notificación click
|
|
23426
|
+
if (event.data?.type === 'NOTIFICATION_CLICK') {
|
|
23427
|
+
this.ngZone.run(() => {
|
|
23428
|
+
const notification = event.data.notification;
|
|
23429
|
+
const action = this.extractActionFromData(notification.data);
|
|
23430
|
+
this.notificationClickSubject.next({
|
|
23431
|
+
notification,
|
|
23432
|
+
action,
|
|
23433
|
+
timestamp: new Date(),
|
|
23434
|
+
});
|
|
23435
|
+
});
|
|
23436
|
+
}
|
|
23437
|
+
});
|
|
23438
|
+
}
|
|
23439
|
+
/**
|
|
23440
|
+
* Verifica si FCM está soportado en el navegador actual
|
|
23441
|
+
*/
|
|
23442
|
+
async checkSupport() {
|
|
23443
|
+
if (!isPlatformBrowser(this.platformId)) {
|
|
23444
|
+
return false;
|
|
23445
|
+
}
|
|
23446
|
+
// Verificar APIs necesarias
|
|
23447
|
+
if (!('Notification' in window)) {
|
|
23448
|
+
return false;
|
|
23449
|
+
}
|
|
23450
|
+
if (!('serviceWorker' in navigator)) {
|
|
23451
|
+
return false;
|
|
23452
|
+
}
|
|
23453
|
+
// Verificar que messaging esté disponible
|
|
23454
|
+
if (!this.messaging) {
|
|
23455
|
+
return false;
|
|
23456
|
+
}
|
|
23457
|
+
return true;
|
|
23458
|
+
}
|
|
23459
|
+
// ===========================================================================
|
|
23460
|
+
// PERMISOS Y TOKEN
|
|
23461
|
+
// ===========================================================================
|
|
23462
|
+
/**
|
|
23463
|
+
* Solicita permiso de notificaciones y obtiene el token FCM.
|
|
23464
|
+
*
|
|
23465
|
+
* @returns Token FCM si se otorgó permiso, null si se denegó
|
|
23466
|
+
*
|
|
23467
|
+
* @example
|
|
23468
|
+
* ```typescript
|
|
23469
|
+
* const token = await messaging.requestPermission();
|
|
23470
|
+
* if (token) {
|
|
23471
|
+
* console.log('Token FCM:', token);
|
|
23472
|
+
* // Enviar a backend
|
|
23473
|
+
* } else {
|
|
23474
|
+
* console.log('Permiso denegado o no soportado');
|
|
23475
|
+
* }
|
|
23476
|
+
* ```
|
|
23477
|
+
*/
|
|
23478
|
+
async requestPermission() {
|
|
23479
|
+
if (!await this.isSupported()) {
|
|
23480
|
+
console.warn('FCM no está soportado en este navegador');
|
|
23481
|
+
return null;
|
|
23482
|
+
}
|
|
23483
|
+
try {
|
|
23484
|
+
// Solicitar permiso de notificaciones
|
|
23485
|
+
const permission = await Notification.requestPermission();
|
|
23486
|
+
this.stateSubject.next({
|
|
23487
|
+
...this.stateSubject.value,
|
|
23488
|
+
permission: permission,
|
|
23489
|
+
});
|
|
23490
|
+
if (permission !== 'granted') {
|
|
23491
|
+
console.warn('Permiso de notificaciones denegado');
|
|
23492
|
+
return null;
|
|
23493
|
+
}
|
|
23494
|
+
// Obtener token FCM
|
|
23495
|
+
const token = await this.getToken();
|
|
23496
|
+
if (token) {
|
|
23497
|
+
// Configurar listener de mensajes
|
|
23498
|
+
this.setupMessageListener();
|
|
23499
|
+
}
|
|
23500
|
+
return token;
|
|
23501
|
+
}
|
|
23502
|
+
catch (error) {
|
|
23503
|
+
console.error('Error solicitando permiso de notificaciones:', error);
|
|
23504
|
+
return null;
|
|
23505
|
+
}
|
|
23506
|
+
}
|
|
23507
|
+
/**
|
|
23508
|
+
* Obtiene el token FCM actual (sin solicitar permiso).
|
|
23509
|
+
*
|
|
23510
|
+
* @returns Token FCM si está disponible, null si no
|
|
23511
|
+
*
|
|
23512
|
+
* @example
|
|
23513
|
+
* ```typescript
|
|
23514
|
+
* const token = await messaging.getToken();
|
|
23515
|
+
* ```
|
|
23516
|
+
*/
|
|
23517
|
+
async getToken() {
|
|
23518
|
+
if (!this.messaging) {
|
|
23519
|
+
return null;
|
|
23520
|
+
}
|
|
23521
|
+
const vapidKey = this.config.messagingVapidKey;
|
|
23522
|
+
if (!vapidKey) {
|
|
23523
|
+
console.warn('VAPID key no configurada. FCM no funcionará.');
|
|
23524
|
+
return null;
|
|
23525
|
+
}
|
|
23526
|
+
try {
|
|
23527
|
+
const token = await getToken(this.messaging, { vapidKey });
|
|
23528
|
+
this.stateSubject.next({
|
|
23529
|
+
...this.stateSubject.value,
|
|
23530
|
+
token,
|
|
23531
|
+
});
|
|
23532
|
+
return token;
|
|
23533
|
+
}
|
|
23534
|
+
catch (error) {
|
|
23535
|
+
console.error('Error obteniendo token FCM:', error);
|
|
23536
|
+
return null;
|
|
23537
|
+
}
|
|
23538
|
+
}
|
|
23539
|
+
/**
|
|
23540
|
+
* Elimina el token FCM actual (unsubscribe de notificaciones).
|
|
23541
|
+
*
|
|
23542
|
+
* @example
|
|
23543
|
+
* ```typescript
|
|
23544
|
+
* await messaging.deleteToken();
|
|
23545
|
+
* console.log('Token eliminado, no recibirá más notificaciones');
|
|
23546
|
+
* ```
|
|
23547
|
+
*/
|
|
23548
|
+
async deleteToken() {
|
|
23549
|
+
if (!this.messaging) {
|
|
23550
|
+
return;
|
|
23551
|
+
}
|
|
23552
|
+
try {
|
|
23553
|
+
await deleteToken(this.messaging);
|
|
23554
|
+
this.stateSubject.next({
|
|
23555
|
+
...this.stateSubject.value,
|
|
23556
|
+
token: null,
|
|
23557
|
+
});
|
|
23558
|
+
// Limpiar listener de mensajes
|
|
23559
|
+
if (this.unsubscribeOnMessage) {
|
|
23560
|
+
this.unsubscribeOnMessage();
|
|
23561
|
+
this.unsubscribeOnMessage = undefined;
|
|
23562
|
+
}
|
|
23563
|
+
}
|
|
23564
|
+
catch (error) {
|
|
23565
|
+
console.error('Error eliminando token FCM:', error);
|
|
23566
|
+
throw new Error('No se pudo eliminar el token de notificaciones');
|
|
23567
|
+
}
|
|
23568
|
+
}
|
|
23569
|
+
// ===========================================================================
|
|
23570
|
+
// MENSAJES
|
|
23571
|
+
// ===========================================================================
|
|
23572
|
+
/**
|
|
23573
|
+
* Observable de mensajes recibidos en foreground.
|
|
23574
|
+
*
|
|
23575
|
+
* IMPORTANTE: Los mensajes en background son manejados por el Service Worker.
|
|
23576
|
+
*
|
|
23577
|
+
* @returns Observable que emite cuando llega un mensaje en foreground
|
|
23578
|
+
*
|
|
23579
|
+
* @example
|
|
23580
|
+
* ```typescript
|
|
23581
|
+
* messaging.onMessage().subscribe(payload => {
|
|
23582
|
+
* console.log('Mensaje recibido:', payload);
|
|
23583
|
+
* // Mostrar notificación custom o actualizar UI
|
|
23584
|
+
* });
|
|
23585
|
+
* ```
|
|
23586
|
+
*/
|
|
23587
|
+
onMessage() {
|
|
23588
|
+
return this.messageSubject.asObservable();
|
|
23589
|
+
}
|
|
23590
|
+
/**
|
|
23591
|
+
* Configura el listener de mensajes en foreground
|
|
23592
|
+
*/
|
|
23593
|
+
setupMessageListener() {
|
|
23594
|
+
if (!this.messaging || this.unsubscribeOnMessage) {
|
|
23595
|
+
return;
|
|
23596
|
+
}
|
|
23597
|
+
this.unsubscribeOnMessage = onMessage(this.messaging, (payload) => {
|
|
23598
|
+
const notification = {
|
|
23599
|
+
title: payload.notification?.title,
|
|
23600
|
+
body: payload.notification?.body,
|
|
23601
|
+
image: payload.notification?.image,
|
|
23602
|
+
data: payload.data,
|
|
23603
|
+
messageId: payload.messageId,
|
|
23604
|
+
};
|
|
23605
|
+
this.messageSubject.next(notification);
|
|
23606
|
+
});
|
|
23607
|
+
}
|
|
23608
|
+
// ===========================================================================
|
|
23609
|
+
// ESTADO Y UTILIDADES
|
|
23610
|
+
// ===========================================================================
|
|
23611
|
+
/**
|
|
23612
|
+
* Obtiene el estado actual del permiso de notificaciones.
|
|
23613
|
+
*
|
|
23614
|
+
* @returns 'granted' | 'denied' | 'default'
|
|
23615
|
+
*
|
|
23616
|
+
* @example
|
|
23617
|
+
* ```typescript
|
|
23618
|
+
* const permission = messaging.getPermissionState();
|
|
23619
|
+
* if (permission === 'granted') {
|
|
23620
|
+
* // Ya tiene permiso
|
|
23621
|
+
* } else if (permission === 'default') {
|
|
23622
|
+
* // Puede solicitar permiso
|
|
23623
|
+
* } else {
|
|
23624
|
+
* // Denegado, debe habilitar manualmente
|
|
23625
|
+
* }
|
|
23626
|
+
* ```
|
|
23627
|
+
*/
|
|
23628
|
+
getPermissionState() {
|
|
23629
|
+
if (!isPlatformBrowser(this.platformId)) {
|
|
23630
|
+
return 'default';
|
|
23631
|
+
}
|
|
23632
|
+
if (!('Notification' in window)) {
|
|
23633
|
+
return 'denied';
|
|
23634
|
+
}
|
|
23635
|
+
return Notification.permission;
|
|
23636
|
+
}
|
|
23637
|
+
/**
|
|
23638
|
+
* Verifica si FCM está soportado en el navegador actual.
|
|
23639
|
+
*
|
|
23640
|
+
* @returns true si FCM está soportado
|
|
23641
|
+
*
|
|
23642
|
+
* @example
|
|
23643
|
+
* ```typescript
|
|
23644
|
+
* if (await messaging.isSupported()) {
|
|
23645
|
+
* // Puede usar notificaciones push
|
|
23646
|
+
* } else {
|
|
23647
|
+
* // Navegador no soporta o no tiene Service Worker
|
|
23648
|
+
* }
|
|
23649
|
+
* ```
|
|
23650
|
+
*/
|
|
23651
|
+
async isSupported() {
|
|
23652
|
+
return this.checkSupport();
|
|
23653
|
+
}
|
|
23654
|
+
/**
|
|
23655
|
+
* Obtiene el token actual sin hacer request.
|
|
23656
|
+
*
|
|
23657
|
+
* @returns Token almacenado o null
|
|
23658
|
+
*/
|
|
23659
|
+
get currentToken() {
|
|
23660
|
+
return this.stateSubject.value.token;
|
|
23661
|
+
}
|
|
23662
|
+
/**
|
|
23663
|
+
* Observable del estado completo del servicio de messaging.
|
|
23664
|
+
*/
|
|
23665
|
+
get state$() {
|
|
23666
|
+
return this.stateSubject.asObservable();
|
|
23667
|
+
}
|
|
23668
|
+
/**
|
|
23669
|
+
* Verifica si el usuario ya otorgó permiso de notificaciones.
|
|
23670
|
+
*/
|
|
23671
|
+
get hasPermission() {
|
|
23672
|
+
return this.stateSubject.value.permission === 'granted';
|
|
23673
|
+
}
|
|
23674
|
+
// ===========================================================================
|
|
23675
|
+
// DEEP LINKING / NAVEGACIÓN
|
|
23676
|
+
// ===========================================================================
|
|
23677
|
+
/**
|
|
23678
|
+
* Observable de clicks en notificaciones.
|
|
23679
|
+
*
|
|
23680
|
+
* Emite cuando el usuario hace click en una notificación (foreground o background).
|
|
23681
|
+
* Usa este observable para navegar a la página correspondiente.
|
|
23682
|
+
*
|
|
23683
|
+
* @returns Observable que emite NotificationClickEvent
|
|
23684
|
+
*
|
|
23685
|
+
* @example
|
|
23686
|
+
* ```typescript
|
|
23687
|
+
* @Component({...})
|
|
23688
|
+
* export class AppComponent {
|
|
23689
|
+
* private messaging = inject(MessagingService);
|
|
23690
|
+
* private router = inject(Router);
|
|
23691
|
+
*
|
|
23692
|
+
* constructor() {
|
|
23693
|
+
* this.messaging.onNotificationClick().subscribe(event => {
|
|
23694
|
+
* if (event.action.route) {
|
|
23695
|
+
* this.router.navigate([event.action.route], {
|
|
23696
|
+
* queryParams: event.action.queryParams
|
|
23697
|
+
* });
|
|
23698
|
+
* }
|
|
23699
|
+
* });
|
|
23700
|
+
* }
|
|
23701
|
+
* }
|
|
23702
|
+
* ```
|
|
23703
|
+
*/
|
|
23704
|
+
onNotificationClick() {
|
|
23705
|
+
return this.notificationClickSubject.asObservable();
|
|
23706
|
+
}
|
|
23707
|
+
/**
|
|
23708
|
+
* Extrae la acción de navegación de los datos de una notificación.
|
|
23709
|
+
*
|
|
23710
|
+
* Busca campos específicos en el payload de datos:
|
|
23711
|
+
* - `route`: Ruta interna de la app (ej: '/orders/123')
|
|
23712
|
+
* - `url`: URL externa (ej: 'https://example.com')
|
|
23713
|
+
* - `action_type`: Tipo de acción personalizada
|
|
23714
|
+
* - Campos con prefijo `action_`: Datos adicionales
|
|
23715
|
+
*
|
|
23716
|
+
* @param data - Datos del payload de la notificación
|
|
23717
|
+
* @returns Acción de navegación extraída
|
|
23718
|
+
*
|
|
23719
|
+
* @example
|
|
23720
|
+
* ```typescript
|
|
23721
|
+
* // Payload desde el backend:
|
|
23722
|
+
* // { route: '/orders/123', action_type: 'view_order', action_orderId: '123' }
|
|
23723
|
+
*
|
|
23724
|
+
* const action = messaging.extractActionFromData(notification.data);
|
|
23725
|
+
* // { route: '/orders/123', actionType: 'view_order', actionData: { orderId: '123' } }
|
|
23726
|
+
* ```
|
|
23727
|
+
*/
|
|
23728
|
+
extractActionFromData(data) {
|
|
23729
|
+
if (!data) {
|
|
23730
|
+
return {};
|
|
23731
|
+
}
|
|
23732
|
+
const action = {};
|
|
23733
|
+
// Ruta interna
|
|
23734
|
+
if (data['route']) {
|
|
23735
|
+
action.route = data['route'];
|
|
23736
|
+
}
|
|
23737
|
+
// URL externa
|
|
23738
|
+
if (data['url']) {
|
|
23739
|
+
action.url = data['url'];
|
|
23740
|
+
}
|
|
23741
|
+
// Tipo de acción
|
|
23742
|
+
if (data['action_type']) {
|
|
23743
|
+
action.actionType = data['action_type'];
|
|
23744
|
+
}
|
|
23745
|
+
// Query params (puede venir como JSON string)
|
|
23746
|
+
if (data['query_params']) {
|
|
23747
|
+
try {
|
|
23748
|
+
action.queryParams = JSON.parse(data['query_params']);
|
|
23749
|
+
}
|
|
23750
|
+
catch {
|
|
23751
|
+
// Si no es JSON válido, intentar parsear como key=value
|
|
23752
|
+
action.queryParams = this.parseQueryString(data['query_params']);
|
|
23753
|
+
}
|
|
23754
|
+
}
|
|
23755
|
+
// Datos adicionales con prefijo action_
|
|
23756
|
+
const actionData = {};
|
|
23757
|
+
for (const [key, value] of Object.entries(data)) {
|
|
23758
|
+
if (key.startsWith('action_') && key !== 'action_type') {
|
|
23759
|
+
const cleanKey = key.replace('action_', '');
|
|
23760
|
+
// Intentar parsear JSON si es posible
|
|
23761
|
+
try {
|
|
23762
|
+
actionData[cleanKey] = JSON.parse(value);
|
|
23763
|
+
}
|
|
23764
|
+
catch {
|
|
23765
|
+
actionData[cleanKey] = value;
|
|
23766
|
+
}
|
|
23767
|
+
}
|
|
23768
|
+
}
|
|
23769
|
+
if (Object.keys(actionData).length > 0) {
|
|
23770
|
+
action.actionData = actionData;
|
|
23771
|
+
}
|
|
23772
|
+
return action;
|
|
23773
|
+
}
|
|
23774
|
+
/**
|
|
23775
|
+
* Emite manualmente un evento de click en notificación.
|
|
23776
|
+
*
|
|
23777
|
+
* Útil para manejar clicks en notificaciones foreground donde
|
|
23778
|
+
* la app decide mostrar un banner custom.
|
|
23779
|
+
*
|
|
23780
|
+
* @param notification - Payload de la notificación
|
|
23781
|
+
*
|
|
23782
|
+
* @example
|
|
23783
|
+
* ```typescript
|
|
23784
|
+
* messaging.onMessage().subscribe(notification => {
|
|
23785
|
+
* // Mostrar banner custom
|
|
23786
|
+
* this.showBanner(notification, () => {
|
|
23787
|
+
* // Usuario hizo click en el banner
|
|
23788
|
+
* messaging.handleNotificationClick(notification);
|
|
23789
|
+
* });
|
|
23790
|
+
* });
|
|
23791
|
+
* ```
|
|
23792
|
+
*/
|
|
23793
|
+
handleNotificationClick(notification) {
|
|
23794
|
+
const action = this.extractActionFromData(notification.data);
|
|
23795
|
+
this.notificationClickSubject.next({
|
|
23796
|
+
notification,
|
|
23797
|
+
action,
|
|
23798
|
+
timestamp: new Date(),
|
|
23799
|
+
});
|
|
23800
|
+
}
|
|
23801
|
+
/**
|
|
23802
|
+
* Verifica si una notificación tiene acción de navegación.
|
|
23803
|
+
*
|
|
23804
|
+
* @param data - Datos del payload
|
|
23805
|
+
* @returns true si tiene route o url
|
|
23806
|
+
*/
|
|
23807
|
+
hasNavigationAction(data) {
|
|
23808
|
+
if (!data)
|
|
23809
|
+
return false;
|
|
23810
|
+
return !!(data['route'] || data['url']);
|
|
23811
|
+
}
|
|
23812
|
+
/**
|
|
23813
|
+
* Parsea un query string en un objeto.
|
|
23814
|
+
*/
|
|
23815
|
+
parseQueryString(queryString) {
|
|
23816
|
+
const params = {};
|
|
23817
|
+
if (!queryString)
|
|
23818
|
+
return params;
|
|
23819
|
+
// Remover ? inicial si existe
|
|
23820
|
+
const cleanQuery = queryString.startsWith('?') ? queryString.slice(1) : queryString;
|
|
23821
|
+
for (const pair of cleanQuery.split('&')) {
|
|
23822
|
+
const [key, value] = pair.split('=');
|
|
23823
|
+
if (key) {
|
|
23824
|
+
params[decodeURIComponent(key)] = decodeURIComponent(value || '');
|
|
23825
|
+
}
|
|
23826
|
+
}
|
|
23827
|
+
return params;
|
|
23828
|
+
}
|
|
23829
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessagingService, deps: [{ token: i1$8.Messaging, optional: true }, { token: VALTECH_FIREBASE_CONFIG }, { token: PLATFORM_ID }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
23830
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessagingService, providedIn: 'root' }); }
|
|
23831
|
+
}
|
|
23832
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessagingService, decorators: [{
|
|
23833
|
+
type: Injectable,
|
|
23834
|
+
args: [{ providedIn: 'root' }]
|
|
23835
|
+
}], ctorParameters: () => [{ type: i1$8.Messaging, decorators: [{
|
|
23836
|
+
type: Optional
|
|
23837
|
+
}] }, { type: undefined, decorators: [{
|
|
23838
|
+
type: Inject,
|
|
23839
|
+
args: [VALTECH_FIREBASE_CONFIG]
|
|
23840
|
+
}] }, { type: Object, decorators: [{
|
|
23841
|
+
type: Inject,
|
|
23842
|
+
args: [PLATFORM_ID]
|
|
23843
|
+
}] }, { type: i0.NgZone }] });
|
|
23844
|
+
|
|
23845
|
+
/**
|
|
23846
|
+
* Firebase Services
|
|
23847
|
+
*
|
|
23848
|
+
* Servicios reutilizables para integración con Firebase.
|
|
23849
|
+
*
|
|
23850
|
+
* @example
|
|
23851
|
+
* ```typescript
|
|
23852
|
+
* // En main.ts
|
|
23853
|
+
* import { provideValtechFirebase } from 'valtech-components';
|
|
23854
|
+
*
|
|
23855
|
+
* bootstrapApplication(AppComponent, {
|
|
23856
|
+
* providers: [
|
|
23857
|
+
* provideValtechFirebase({
|
|
23858
|
+
* firebase: environment.firebase,
|
|
23859
|
+
* persistence: true,
|
|
23860
|
+
* }),
|
|
23861
|
+
* ],
|
|
23862
|
+
* });
|
|
23863
|
+
*
|
|
23864
|
+
* // En componentes
|
|
23865
|
+
* import { FirebaseService, FirestoreService } from 'valtech-components';
|
|
23866
|
+
*
|
|
23867
|
+
* @Component({...})
|
|
23868
|
+
* export class MyComponent {
|
|
23869
|
+
* private firebase = inject(FirebaseService);
|
|
23870
|
+
* private firestore = inject(FirestoreService);
|
|
23871
|
+
* }
|
|
23872
|
+
* ```
|
|
23873
|
+
*/
|
|
23874
|
+
// Tipos
|
|
23875
|
+
|
|
23876
|
+
/**
|
|
23877
|
+
* Tipos e interfaces para el servicio de autenticación de Valtech.
|
|
23878
|
+
* Alineados con el backend AuthV2.
|
|
23879
|
+
*/
|
|
23880
|
+
/**
|
|
23881
|
+
* Estado inicial de autenticación.
|
|
23882
|
+
*/
|
|
23883
|
+
const INITIAL_AUTH_STATE = {
|
|
23884
|
+
isAuthenticated: false,
|
|
23885
|
+
isLoading: true,
|
|
23886
|
+
accessToken: null,
|
|
23887
|
+
refreshToken: null,
|
|
23888
|
+
userId: null,
|
|
23889
|
+
email: null,
|
|
23890
|
+
roles: [],
|
|
23891
|
+
permissions: [],
|
|
23892
|
+
isSuperAdmin: false,
|
|
23893
|
+
expiresAt: null,
|
|
23894
|
+
error: null,
|
|
23895
|
+
};
|
|
23896
|
+
/**
|
|
23897
|
+
* Estado inicial de MFA.
|
|
23898
|
+
*/
|
|
23899
|
+
const INITIAL_MFA_STATE = {
|
|
23900
|
+
required: false,
|
|
23901
|
+
mfaToken: null,
|
|
23902
|
+
method: null,
|
|
23903
|
+
};
|
|
23904
|
+
|
|
23905
|
+
/**
|
|
23906
|
+
* Servicio para manejo de estado de autenticación con Angular Signals.
|
|
23907
|
+
* Proporciona estado reactivo inmutable.
|
|
23908
|
+
*/
|
|
23909
|
+
class AuthStateService {
|
|
23910
|
+
constructor() {
|
|
23911
|
+
// Estado interno (mutable solo dentro del servicio)
|
|
23912
|
+
this._state = signal(INITIAL_AUTH_STATE);
|
|
23913
|
+
this._mfaPending = signal(INITIAL_MFA_STATE);
|
|
23914
|
+
// =============================================
|
|
23915
|
+
// Signals públicos (readonly)
|
|
23916
|
+
// =============================================
|
|
23917
|
+
/** Estado completo de autenticación */
|
|
23918
|
+
this.state = this._state.asReadonly();
|
|
23919
|
+
/** Estado de MFA pendiente */
|
|
23920
|
+
this.mfaPending = this._mfaPending.asReadonly();
|
|
23921
|
+
/** Usuario está autenticado */
|
|
23922
|
+
this.isAuthenticated = computed(() => this._state().isAuthenticated);
|
|
23923
|
+
/** Estado de carga */
|
|
23924
|
+
this.isLoading = computed(() => this._state().isLoading);
|
|
23925
|
+
/** Token de acceso */
|
|
23926
|
+
this.accessToken = computed(() => this._state().accessToken);
|
|
23927
|
+
/** Roles del usuario */
|
|
23928
|
+
this.roles = computed(() => this._state().roles);
|
|
23929
|
+
/** Permisos del usuario */
|
|
23930
|
+
this.permissions = computed(() => this._state().permissions);
|
|
23931
|
+
/** Usuario es super admin */
|
|
23932
|
+
this.isSuperAdmin = computed(() => this._state().isSuperAdmin);
|
|
23933
|
+
/** Error actual */
|
|
23934
|
+
this.error = computed(() => this._state().error);
|
|
23935
|
+
/** Información del usuario */
|
|
23936
|
+
this.user = computed(() => {
|
|
23937
|
+
const state = this._state();
|
|
23938
|
+
if (!state.isAuthenticated || !state.userId) {
|
|
23939
|
+
return null;
|
|
23940
|
+
}
|
|
23941
|
+
return {
|
|
23942
|
+
userId: state.userId,
|
|
23943
|
+
email: state.email || '',
|
|
23944
|
+
roles: state.roles,
|
|
23945
|
+
permissions: state.permissions,
|
|
23946
|
+
isSuperAdmin: state.isSuperAdmin,
|
|
23947
|
+
};
|
|
23948
|
+
});
|
|
23949
|
+
}
|
|
23950
|
+
// =============================================
|
|
23951
|
+
// Métodos de actualización
|
|
23952
|
+
// =============================================
|
|
23953
|
+
/**
|
|
23954
|
+
* Establece el estado de carga.
|
|
23955
|
+
*/
|
|
23956
|
+
setLoading(isLoading) {
|
|
23957
|
+
this._state.update((s) => ({ ...s, isLoading }));
|
|
23958
|
+
}
|
|
23959
|
+
/**
|
|
23960
|
+
* Establece el estado de autenticación exitosa.
|
|
23961
|
+
*/
|
|
23962
|
+
setAuthenticated(data) {
|
|
23963
|
+
this._state.set({
|
|
23964
|
+
isAuthenticated: true,
|
|
23965
|
+
isLoading: false,
|
|
23966
|
+
accessToken: data.accessToken,
|
|
23967
|
+
refreshToken: data.refreshToken,
|
|
23968
|
+
userId: data.userId || null,
|
|
23969
|
+
email: data.email || null,
|
|
23970
|
+
roles: data.roles,
|
|
23971
|
+
permissions: data.permissions,
|
|
23972
|
+
isSuperAdmin: data.isSuperAdmin,
|
|
23973
|
+
expiresAt: data.expiresAt,
|
|
23974
|
+
error: null,
|
|
23975
|
+
});
|
|
23976
|
+
}
|
|
23977
|
+
/**
|
|
23978
|
+
* Actualiza solo el access token (después de refresh).
|
|
23979
|
+
*/
|
|
23980
|
+
updateAccessToken(accessToken, expiresIn) {
|
|
23981
|
+
const expiresAt = Date.now() + expiresIn * 1000;
|
|
23982
|
+
this._state.update((s) => ({
|
|
23983
|
+
...s,
|
|
23984
|
+
accessToken,
|
|
23985
|
+
expiresAt,
|
|
23986
|
+
}));
|
|
23987
|
+
}
|
|
23988
|
+
/**
|
|
23989
|
+
* Actualiza los permisos.
|
|
23990
|
+
*/
|
|
23991
|
+
updatePermissions(roles, permissions, isSuperAdmin) {
|
|
23992
|
+
this._state.update((s) => ({
|
|
23993
|
+
...s,
|
|
23994
|
+
roles,
|
|
23995
|
+
permissions,
|
|
23996
|
+
isSuperAdmin,
|
|
23997
|
+
}));
|
|
23998
|
+
}
|
|
23999
|
+
/**
|
|
24000
|
+
* Establece un error de autenticación.
|
|
24001
|
+
*/
|
|
24002
|
+
setError(error) {
|
|
24003
|
+
this._state.update((s) => ({
|
|
24004
|
+
...s,
|
|
24005
|
+
error,
|
|
24006
|
+
isLoading: false,
|
|
24007
|
+
}));
|
|
24008
|
+
}
|
|
24009
|
+
/**
|
|
24010
|
+
* Limpia el error.
|
|
24011
|
+
*/
|
|
24012
|
+
clearError() {
|
|
24013
|
+
this._state.update((s) => ({
|
|
24014
|
+
...s,
|
|
24015
|
+
error: null,
|
|
24016
|
+
}));
|
|
24017
|
+
}
|
|
24018
|
+
/**
|
|
24019
|
+
* Establece estado de MFA pendiente.
|
|
24020
|
+
*/
|
|
24021
|
+
setMFAPending(mfaState) {
|
|
24022
|
+
this._mfaPending.set(mfaState);
|
|
24023
|
+
}
|
|
24024
|
+
/**
|
|
24025
|
+
* Limpia el estado de MFA pendiente.
|
|
24026
|
+
*/
|
|
24027
|
+
clearMFAPending() {
|
|
24028
|
+
this._mfaPending.set(INITIAL_MFA_STATE);
|
|
24029
|
+
}
|
|
24030
|
+
/**
|
|
24031
|
+
* Resetea todo el estado a valores iniciales.
|
|
24032
|
+
*/
|
|
24033
|
+
reset() {
|
|
24034
|
+
this._state.set(INITIAL_AUTH_STATE);
|
|
24035
|
+
this._mfaPending.set(INITIAL_MFA_STATE);
|
|
24036
|
+
}
|
|
24037
|
+
/**
|
|
24038
|
+
* Restaura estado desde datos almacenados.
|
|
24039
|
+
*/
|
|
24040
|
+
restoreFromStorage(stored) {
|
|
24041
|
+
if (stored.accessToken) {
|
|
24042
|
+
this._state.set({
|
|
24043
|
+
isAuthenticated: true,
|
|
24044
|
+
isLoading: false,
|
|
24045
|
+
accessToken: stored.accessToken,
|
|
24046
|
+
refreshToken: stored.refreshToken || null,
|
|
24047
|
+
userId: null, // Se extraerá del token
|
|
24048
|
+
email: null, // Se extraerá del token
|
|
24049
|
+
roles: stored.roles || [],
|
|
24050
|
+
permissions: stored.permissions || [],
|
|
24051
|
+
isSuperAdmin: stored.isSuperAdmin || false,
|
|
24052
|
+
expiresAt: stored.expiresAt || null,
|
|
24053
|
+
error: null,
|
|
24054
|
+
});
|
|
24055
|
+
}
|
|
24056
|
+
}
|
|
24057
|
+
/**
|
|
24058
|
+
* Actualiza el userId y email (después de parsear el token).
|
|
24059
|
+
*/
|
|
24060
|
+
updateUserInfo(userId, email) {
|
|
24061
|
+
this._state.update((s) => ({
|
|
24062
|
+
...s,
|
|
24063
|
+
userId,
|
|
24064
|
+
email,
|
|
24065
|
+
}));
|
|
24066
|
+
}
|
|
24067
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
24068
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthStateService, providedIn: 'root' }); }
|
|
24069
|
+
}
|
|
24070
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthStateService, decorators: [{
|
|
24071
|
+
type: Injectable,
|
|
24072
|
+
args: [{ providedIn: 'root' }]
|
|
24073
|
+
}] });
|
|
24074
|
+
|
|
24075
|
+
/**
|
|
24076
|
+
* Servicio para manejo de tokens JWT.
|
|
24077
|
+
* Parseo y validación de tokens sin dependencias externas.
|
|
24078
|
+
*/
|
|
24079
|
+
class TokenService {
|
|
24080
|
+
/**
|
|
24081
|
+
* Parsea un token JWT y extrae los claims.
|
|
24082
|
+
* @param token - Token JWT
|
|
24083
|
+
* @returns Claims del token o null si es inválido
|
|
24084
|
+
*/
|
|
24085
|
+
parseToken(token) {
|
|
24086
|
+
try {
|
|
24087
|
+
const parts = token.split('.');
|
|
24088
|
+
if (parts.length !== 3) {
|
|
24089
|
+
return null;
|
|
24090
|
+
}
|
|
24091
|
+
const payload = parts[1];
|
|
24092
|
+
const decoded = this.base64UrlDecode(payload);
|
|
24093
|
+
return JSON.parse(decoded);
|
|
24094
|
+
}
|
|
24095
|
+
catch {
|
|
24096
|
+
console.warn('[ValtechAuth] Error al parsear token JWT');
|
|
24097
|
+
return null;
|
|
24098
|
+
}
|
|
24099
|
+
}
|
|
24100
|
+
/**
|
|
24101
|
+
* Verifica si un token es válido (no expirado).
|
|
24102
|
+
* @param token - Token JWT
|
|
24103
|
+
* @returns true si el token es válido
|
|
24104
|
+
*/
|
|
24105
|
+
isTokenValid(token) {
|
|
24106
|
+
const claims = this.parseToken(token);
|
|
24107
|
+
if (!claims) {
|
|
24108
|
+
return false;
|
|
24109
|
+
}
|
|
24110
|
+
// exp está en segundos, Date.now() en milisegundos
|
|
24111
|
+
const expirationMs = claims.exp * 1000;
|
|
24112
|
+
return Date.now() < expirationMs;
|
|
24113
|
+
}
|
|
24114
|
+
/**
|
|
24115
|
+
* Obtiene el tiempo restante del token en segundos.
|
|
24116
|
+
* @param token - Token JWT
|
|
24117
|
+
* @returns Segundos restantes o 0 si expirado
|
|
24118
|
+
*/
|
|
24119
|
+
getTimeToExpiry(token) {
|
|
24120
|
+
const claims = this.parseToken(token);
|
|
24121
|
+
if (!claims) {
|
|
24122
|
+
return 0;
|
|
24123
|
+
}
|
|
24124
|
+
const expirationMs = claims.exp * 1000;
|
|
24125
|
+
const remaining = expirationMs - Date.now();
|
|
24126
|
+
return remaining > 0 ? Math.floor(remaining / 1000) : 0;
|
|
24127
|
+
}
|
|
24128
|
+
/**
|
|
24129
|
+
* Obtiene el timestamp de expiración del token.
|
|
24130
|
+
* @param token - Token JWT
|
|
24131
|
+
* @returns Timestamp en milisegundos o null
|
|
24132
|
+
*/
|
|
24133
|
+
getExpirationTime(token) {
|
|
24134
|
+
const claims = this.parseToken(token);
|
|
24135
|
+
if (!claims) {
|
|
24136
|
+
return null;
|
|
24137
|
+
}
|
|
24138
|
+
return claims.exp * 1000;
|
|
24139
|
+
}
|
|
24140
|
+
/**
|
|
24141
|
+
* Extrae el user ID del token.
|
|
24142
|
+
* @param token - Token JWT
|
|
24143
|
+
* @returns User ID o null
|
|
24144
|
+
*/
|
|
24145
|
+
getUserId(token) {
|
|
24146
|
+
const claims = this.parseToken(token);
|
|
24147
|
+
return claims?.uid || null;
|
|
24148
|
+
}
|
|
24149
|
+
/**
|
|
24150
|
+
* Extrae el email del token.
|
|
24151
|
+
* @param token - Token JWT
|
|
24152
|
+
* @returns Email o null
|
|
24153
|
+
*/
|
|
24154
|
+
getEmail(token) {
|
|
24155
|
+
const claims = this.parseToken(token);
|
|
24156
|
+
return claims?.email || null;
|
|
24157
|
+
}
|
|
24158
|
+
/**
|
|
24159
|
+
* Decodifica base64url a string.
|
|
24160
|
+
* Base64url usa - y _ en lugar de + y /
|
|
24161
|
+
*/
|
|
24162
|
+
base64UrlDecode(str) {
|
|
24163
|
+
// Reemplazar caracteres base64url por base64 estándar
|
|
24164
|
+
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
24165
|
+
// Agregar padding si es necesario
|
|
24166
|
+
const padding = base64.length % 4;
|
|
24167
|
+
if (padding) {
|
|
24168
|
+
base64 += '='.repeat(4 - padding);
|
|
24169
|
+
}
|
|
24170
|
+
// Decodificar
|
|
24171
|
+
const decoded = atob(base64);
|
|
24172
|
+
// Manejar caracteres UTF-8
|
|
24173
|
+
return decodeURIComponent(decoded
|
|
24174
|
+
.split('')
|
|
24175
|
+
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
|
24176
|
+
.join(''));
|
|
24177
|
+
}
|
|
24178
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TokenService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
24179
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TokenService, providedIn: 'root' }); }
|
|
24180
|
+
}
|
|
24181
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TokenService, decorators: [{
|
|
24182
|
+
type: Injectable,
|
|
24183
|
+
args: [{ providedIn: 'root' }]
|
|
24184
|
+
}] });
|
|
24185
|
+
|
|
24186
|
+
/**
|
|
24187
|
+
* Servicio para persistencia de estado de autenticación en localStorage.
|
|
24188
|
+
*/
|
|
24189
|
+
class AuthStorageService {
|
|
24190
|
+
constructor(config) {
|
|
24191
|
+
this.config = config;
|
|
24192
|
+
const prefix = this.config.storagePrefix || 'valtech_auth_';
|
|
24193
|
+
this.keys = {
|
|
24194
|
+
ACCESS_TOKEN: `${prefix}access_token`,
|
|
24195
|
+
REFRESH_TOKEN: `${prefix}refresh_token`,
|
|
24196
|
+
ROLES: `${prefix}roles`,
|
|
24197
|
+
PERMISSIONS: `${prefix}permissions`,
|
|
24198
|
+
IS_SUPER_ADMIN: `${prefix}is_super_admin`,
|
|
24199
|
+
EXPIRES_AT: `${prefix}expires_at`,
|
|
24200
|
+
};
|
|
24201
|
+
}
|
|
24202
|
+
/**
|
|
24203
|
+
* Guarda el estado completo de autenticación.
|
|
24204
|
+
*/
|
|
24205
|
+
saveState(state) {
|
|
24206
|
+
try {
|
|
24207
|
+
localStorage.setItem(this.keys.ACCESS_TOKEN, state.accessToken);
|
|
24208
|
+
localStorage.setItem(this.keys.REFRESH_TOKEN, state.refreshToken);
|
|
24209
|
+
localStorage.setItem(this.keys.ROLES, JSON.stringify(state.roles));
|
|
24210
|
+
localStorage.setItem(this.keys.PERMISSIONS, JSON.stringify(state.permissions));
|
|
24211
|
+
localStorage.setItem(this.keys.IS_SUPER_ADMIN, String(state.isSuperAdmin));
|
|
24212
|
+
if (state.expiresAt) {
|
|
24213
|
+
localStorage.setItem(this.keys.EXPIRES_AT, String(state.expiresAt));
|
|
24214
|
+
}
|
|
24215
|
+
}
|
|
24216
|
+
catch (e) {
|
|
24217
|
+
console.warn('[ValtechAuth] Error guardando estado en storage:', e);
|
|
24218
|
+
}
|
|
24219
|
+
}
|
|
24220
|
+
/**
|
|
24221
|
+
* Carga el estado de autenticación desde storage.
|
|
24222
|
+
*/
|
|
24223
|
+
loadState() {
|
|
24224
|
+
try {
|
|
24225
|
+
const accessToken = localStorage.getItem(this.keys.ACCESS_TOKEN);
|
|
24226
|
+
const refreshToken = localStorage.getItem(this.keys.REFRESH_TOKEN);
|
|
24227
|
+
const rolesJson = localStorage.getItem(this.keys.ROLES);
|
|
24228
|
+
const permissionsJson = localStorage.getItem(this.keys.PERMISSIONS);
|
|
24229
|
+
const isSuperAdmin = localStorage.getItem(this.keys.IS_SUPER_ADMIN) === 'true';
|
|
24230
|
+
const expiresAtStr = localStorage.getItem(this.keys.EXPIRES_AT);
|
|
24231
|
+
return {
|
|
24232
|
+
accessToken: accessToken || undefined,
|
|
24233
|
+
refreshToken: refreshToken || undefined,
|
|
24234
|
+
roles: rolesJson ? JSON.parse(rolesJson) : [],
|
|
24235
|
+
permissions: permissionsJson ? JSON.parse(permissionsJson) : [],
|
|
24236
|
+
isSuperAdmin,
|
|
24237
|
+
expiresAt: expiresAtStr ? Number(expiresAtStr) : undefined,
|
|
24238
|
+
};
|
|
24239
|
+
}
|
|
24240
|
+
catch (e) {
|
|
24241
|
+
console.warn('[ValtechAuth] Error cargando estado desde storage:', e);
|
|
24242
|
+
return {};
|
|
24243
|
+
}
|
|
24244
|
+
}
|
|
24245
|
+
/**
|
|
24246
|
+
* Guarda solo el access token.
|
|
24247
|
+
*/
|
|
24248
|
+
saveAccessToken(token, expiresAt) {
|
|
24249
|
+
try {
|
|
24250
|
+
localStorage.setItem(this.keys.ACCESS_TOKEN, token);
|
|
24251
|
+
if (expiresAt) {
|
|
24252
|
+
localStorage.setItem(this.keys.EXPIRES_AT, String(expiresAt));
|
|
24253
|
+
}
|
|
24254
|
+
}
|
|
24255
|
+
catch (e) {
|
|
24256
|
+
console.warn('[ValtechAuth] Error guardando access token:', e);
|
|
24257
|
+
}
|
|
24258
|
+
}
|
|
24259
|
+
/**
|
|
24260
|
+
* Guarda los permisos actualizados.
|
|
24261
|
+
*/
|
|
24262
|
+
savePermissions(response) {
|
|
24263
|
+
try {
|
|
24264
|
+
localStorage.setItem(this.keys.ROLES, JSON.stringify(response.roles));
|
|
24265
|
+
localStorage.setItem(this.keys.PERMISSIONS, JSON.stringify(response.permissions));
|
|
24266
|
+
localStorage.setItem(this.keys.IS_SUPER_ADMIN, String(response.isSuperAdmin));
|
|
24267
|
+
}
|
|
24268
|
+
catch (e) {
|
|
24269
|
+
console.warn('[ValtechAuth] Error guardando permisos:', e);
|
|
24270
|
+
}
|
|
24271
|
+
}
|
|
24272
|
+
/**
|
|
24273
|
+
* Carga los permisos desde storage.
|
|
24274
|
+
*/
|
|
24275
|
+
loadPermissions() {
|
|
24276
|
+
try {
|
|
24277
|
+
const rolesJson = localStorage.getItem(this.keys.ROLES);
|
|
24278
|
+
const permissionsJson = localStorage.getItem(this.keys.PERMISSIONS);
|
|
24279
|
+
const isSuperAdmin = localStorage.getItem(this.keys.IS_SUPER_ADMIN) === 'true';
|
|
24280
|
+
return {
|
|
24281
|
+
roles: rolesJson ? JSON.parse(rolesJson) : [],
|
|
24282
|
+
permissions: permissionsJson ? JSON.parse(permissionsJson) : [],
|
|
24283
|
+
isSuperAdmin,
|
|
24284
|
+
};
|
|
24285
|
+
}
|
|
24286
|
+
catch {
|
|
24287
|
+
return { roles: [], permissions: [], isSuperAdmin: false };
|
|
24288
|
+
}
|
|
24289
|
+
}
|
|
24290
|
+
/**
|
|
24291
|
+
* Obtiene el refresh token.
|
|
24292
|
+
*/
|
|
24293
|
+
getRefreshToken() {
|
|
24294
|
+
return localStorage.getItem(this.keys.REFRESH_TOKEN);
|
|
24295
|
+
}
|
|
24296
|
+
/**
|
|
24297
|
+
* Limpia todo el estado de autenticación.
|
|
24298
|
+
*/
|
|
24299
|
+
clear() {
|
|
24300
|
+
try {
|
|
24301
|
+
Object.values(this.keys).forEach((key) => localStorage.removeItem(key));
|
|
24302
|
+
}
|
|
24303
|
+
catch (e) {
|
|
24304
|
+
console.warn('[ValtechAuth] Error limpiando storage:', e);
|
|
24305
|
+
}
|
|
24306
|
+
}
|
|
24307
|
+
/**
|
|
24308
|
+
* Verifica si hay estado guardado.
|
|
24309
|
+
*/
|
|
24310
|
+
hasStoredState() {
|
|
24311
|
+
return !!localStorage.getItem(this.keys.ACCESS_TOKEN);
|
|
24312
|
+
}
|
|
24313
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthStorageService, deps: [{ token: VALTECH_AUTH_CONFIG }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
24314
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthStorageService, providedIn: 'root' }); }
|
|
24315
|
+
}
|
|
24316
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthStorageService, decorators: [{
|
|
24317
|
+
type: Injectable,
|
|
24318
|
+
args: [{ providedIn: 'root' }]
|
|
24319
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
24320
|
+
type: Inject,
|
|
24321
|
+
args: [VALTECH_AUTH_CONFIG]
|
|
24322
|
+
}] }] });
|
|
24323
|
+
|
|
24324
|
+
/**
|
|
24325
|
+
* Servicio para sincronización de estado de autenticación entre pestañas.
|
|
24326
|
+
* Usa BroadcastChannel API con fallback a storage events.
|
|
24327
|
+
*/
|
|
24328
|
+
class AuthSyncService {
|
|
24329
|
+
constructor(config) {
|
|
24330
|
+
this.config = config;
|
|
24331
|
+
this.channel = null;
|
|
24332
|
+
this.eventSubject = new Subject();
|
|
24333
|
+
this.storageListener = null;
|
|
24334
|
+
/** Observable de eventos de sincronización */
|
|
24335
|
+
this.onEvent$ = this.eventSubject.asObservable();
|
|
24336
|
+
const prefix = this.config.storagePrefix || 'valtech_auth_';
|
|
24337
|
+
this.channelName = `${prefix}sync_channel`;
|
|
24338
|
+
}
|
|
24339
|
+
/**
|
|
24340
|
+
* Inicia la sincronización entre pestañas.
|
|
24341
|
+
*/
|
|
24342
|
+
start() {
|
|
24343
|
+
if (!this.config.enableTabSync) {
|
|
24344
|
+
return;
|
|
24345
|
+
}
|
|
24346
|
+
// Intentar usar BroadcastChannel API (mejor rendimiento)
|
|
24347
|
+
if (typeof BroadcastChannel !== 'undefined') {
|
|
24348
|
+
this.initBroadcastChannel();
|
|
24349
|
+
}
|
|
24350
|
+
else {
|
|
24351
|
+
// Fallback a storage events
|
|
24352
|
+
this.initStorageEvents();
|
|
24353
|
+
}
|
|
24354
|
+
}
|
|
24355
|
+
/**
|
|
24356
|
+
* Detiene la sincronización.
|
|
24357
|
+
*/
|
|
24358
|
+
stop() {
|
|
24359
|
+
if (this.channel) {
|
|
24360
|
+
this.channel.close();
|
|
24361
|
+
this.channel = null;
|
|
24362
|
+
}
|
|
24363
|
+
if (this.storageListener) {
|
|
24364
|
+
window.removeEventListener('storage', this.storageListener);
|
|
24365
|
+
this.storageListener = null;
|
|
24366
|
+
}
|
|
24367
|
+
}
|
|
24368
|
+
/**
|
|
24369
|
+
* Envía un evento a otras pestañas.
|
|
24370
|
+
*/
|
|
24371
|
+
broadcast(event) {
|
|
24372
|
+
if (!this.config.enableTabSync) {
|
|
24373
|
+
return;
|
|
24374
|
+
}
|
|
24375
|
+
const fullEvent = {
|
|
24376
|
+
...event,
|
|
24377
|
+
timestamp: Date.now(),
|
|
24378
|
+
};
|
|
24379
|
+
if (this.channel) {
|
|
24380
|
+
this.channel.postMessage(fullEvent);
|
|
24381
|
+
}
|
|
24382
|
+
else {
|
|
24383
|
+
// Fallback: usar localStorage para notificar
|
|
24384
|
+
this.broadcastViaStorage(fullEvent);
|
|
24385
|
+
}
|
|
24386
|
+
}
|
|
24387
|
+
ngOnDestroy() {
|
|
24388
|
+
this.stop();
|
|
24389
|
+
this.eventSubject.complete();
|
|
24390
|
+
}
|
|
24391
|
+
/**
|
|
24392
|
+
* Inicializa BroadcastChannel API.
|
|
24393
|
+
*/
|
|
24394
|
+
initBroadcastChannel() {
|
|
24395
|
+
try {
|
|
24396
|
+
this.channel = new BroadcastChannel(this.channelName);
|
|
24397
|
+
this.channel.onmessage = (event) => {
|
|
24398
|
+
this.handleEvent(event.data);
|
|
24399
|
+
};
|
|
24400
|
+
this.channel.onmessageerror = () => {
|
|
24401
|
+
console.warn('[ValtechAuth] Error en BroadcastChannel, usando fallback');
|
|
24402
|
+
this.channel?.close();
|
|
24403
|
+
this.channel = null;
|
|
24404
|
+
this.initStorageEvents();
|
|
24405
|
+
};
|
|
24406
|
+
}
|
|
24407
|
+
catch {
|
|
24408
|
+
// BroadcastChannel no disponible, usar fallback
|
|
24409
|
+
this.initStorageEvents();
|
|
24410
|
+
}
|
|
24411
|
+
}
|
|
24412
|
+
/**
|
|
24413
|
+
* Inicializa fallback con storage events.
|
|
24414
|
+
*/
|
|
24415
|
+
initStorageEvents() {
|
|
24416
|
+
const storageKey = `${this.config.storagePrefix}sync_event`;
|
|
24417
|
+
this.storageListener = (event) => {
|
|
24418
|
+
if (event.key === storageKey && event.newValue) {
|
|
24419
|
+
try {
|
|
24420
|
+
const syncEvent = JSON.parse(event.newValue);
|
|
24421
|
+
this.handleEvent(syncEvent);
|
|
24422
|
+
}
|
|
24423
|
+
catch {
|
|
24424
|
+
// Ignorar eventos mal formados
|
|
24425
|
+
}
|
|
24426
|
+
}
|
|
24427
|
+
};
|
|
24428
|
+
window.addEventListener('storage', this.storageListener);
|
|
24429
|
+
}
|
|
24430
|
+
/**
|
|
24431
|
+
* Envía evento via localStorage (fallback).
|
|
24432
|
+
*/
|
|
24433
|
+
broadcastViaStorage(event) {
|
|
24434
|
+
const storageKey = `${this.config.storagePrefix}sync_event`;
|
|
24435
|
+
try {
|
|
24436
|
+
// Escribir y luego limpiar para permitir múltiples eventos del mismo tipo
|
|
24437
|
+
localStorage.setItem(storageKey, JSON.stringify(event));
|
|
24438
|
+
// Usar setTimeout para permitir que otras pestañas lean el valor
|
|
24439
|
+
setTimeout(() => {
|
|
24440
|
+
localStorage.removeItem(storageKey);
|
|
24441
|
+
}, 100);
|
|
24442
|
+
}
|
|
24443
|
+
catch {
|
|
24444
|
+
console.warn('[ValtechAuth] Error enviando evento via storage');
|
|
24445
|
+
}
|
|
24446
|
+
}
|
|
24447
|
+
/**
|
|
24448
|
+
* Maneja un evento recibido.
|
|
24449
|
+
*/
|
|
24450
|
+
handleEvent(event) {
|
|
24451
|
+
// Verificar que el evento no sea muy antiguo (más de 5 segundos)
|
|
24452
|
+
const age = Date.now() - event.timestamp;
|
|
24453
|
+
if (age > 5000) {
|
|
24454
|
+
return;
|
|
24455
|
+
}
|
|
24456
|
+
this.eventSubject.next(event);
|
|
24457
|
+
}
|
|
24458
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthSyncService, deps: [{ token: VALTECH_AUTH_CONFIG }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
24459
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthSyncService, providedIn: 'root' }); }
|
|
24460
|
+
}
|
|
24461
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthSyncService, decorators: [{
|
|
24462
|
+
type: Injectable,
|
|
24463
|
+
args: [{ providedIn: 'root' }]
|
|
24464
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
24465
|
+
type: Inject,
|
|
24466
|
+
args: [VALTECH_AUTH_CONFIG]
|
|
24467
|
+
}] }] });
|
|
24468
|
+
|
|
24469
|
+
/**
|
|
24470
|
+
* Servicio principal de autenticación.
|
|
24471
|
+
*
|
|
24472
|
+
* @example
|
|
24473
|
+
* ```typescript
|
|
24474
|
+
* import { AuthService } from 'valtech-components';
|
|
24475
|
+
*
|
|
24476
|
+
* @Component({...})
|
|
24477
|
+
* export class LoginComponent {
|
|
24478
|
+
* private auth = inject(AuthService);
|
|
24479
|
+
*
|
|
24480
|
+
* async login() {
|
|
24481
|
+
* await firstValueFrom(this.auth.signin({ email, password }));
|
|
24482
|
+
* if (this.auth.mfaPending().required) {
|
|
24483
|
+
* // Mostrar UI de MFA
|
|
24484
|
+
* } else {
|
|
24485
|
+
* this.router.navigate(['/']);
|
|
24486
|
+
* }
|
|
24487
|
+
* }
|
|
24488
|
+
* }
|
|
24489
|
+
* ```
|
|
24490
|
+
*/
|
|
24491
|
+
class AuthService {
|
|
24492
|
+
constructor(config, http, router, stateService, tokenService, storageService, syncService, firebaseService) {
|
|
24493
|
+
this.config = config;
|
|
24494
|
+
this.http = http;
|
|
24495
|
+
this.router = router;
|
|
24496
|
+
this.stateService = stateService;
|
|
24497
|
+
this.tokenService = tokenService;
|
|
24498
|
+
this.storageService = storageService;
|
|
24499
|
+
this.syncService = syncService;
|
|
24500
|
+
this.firebaseService = firebaseService;
|
|
24501
|
+
// Timer para refresh proactivo
|
|
24502
|
+
this.refreshTimerId = null;
|
|
24503
|
+
this.syncSubscription = null;
|
|
24504
|
+
// =============================================
|
|
24505
|
+
// ESTADO PÚBLICO (Signals readonly)
|
|
24506
|
+
// =============================================
|
|
24507
|
+
/** Estado completo de autenticación */
|
|
24508
|
+
this.state = this.stateService.state;
|
|
24509
|
+
/** Usuario está autenticado */
|
|
24510
|
+
this.isAuthenticated = this.stateService.isAuthenticated;
|
|
24511
|
+
/** Estado de carga */
|
|
24512
|
+
this.isLoading = this.stateService.isLoading;
|
|
24513
|
+
/** Información del usuario */
|
|
24514
|
+
this.user = this.stateService.user;
|
|
24515
|
+
/** Token de acceso */
|
|
24516
|
+
this.accessToken = this.stateService.accessToken;
|
|
24517
|
+
/** Roles del usuario */
|
|
24518
|
+
this.roles = this.stateService.roles;
|
|
24519
|
+
/** Permisos del usuario */
|
|
24520
|
+
this.permissions = this.stateService.permissions;
|
|
24521
|
+
/** Usuario es super admin */
|
|
24522
|
+
this.isSuperAdmin = this.stateService.isSuperAdmin;
|
|
24523
|
+
/** Estado de MFA pendiente */
|
|
24524
|
+
this.mfaPending = this.stateService.mfaPending;
|
|
24525
|
+
/** Error actual */
|
|
24526
|
+
this.error = this.stateService.error;
|
|
24527
|
+
}
|
|
24528
|
+
// =============================================
|
|
24529
|
+
// INICIALIZACIÓN
|
|
24530
|
+
// =============================================
|
|
24531
|
+
/**
|
|
24532
|
+
* Inicializa el servicio de autenticación.
|
|
24533
|
+
* Llamado automáticamente por provideValtechAuth.
|
|
24534
|
+
*/
|
|
24535
|
+
async initialize() {
|
|
24536
|
+
// 1. Cargar estado desde storage
|
|
24537
|
+
const storedState = this.storageService.loadState();
|
|
24538
|
+
if (storedState.accessToken) {
|
|
24539
|
+
// 2. Verificar si token es válido
|
|
24540
|
+
if (this.tokenService.isTokenValid(storedState.accessToken)) {
|
|
24541
|
+
this.stateService.restoreFromStorage(storedState);
|
|
24542
|
+
// Extraer info del token
|
|
24543
|
+
const claims = this.tokenService.parseToken(storedState.accessToken);
|
|
24544
|
+
if (claims) {
|
|
24545
|
+
this.stateService.updateUserInfo(claims.uid, claims.email);
|
|
24546
|
+
}
|
|
24547
|
+
// 3. Iniciar timer de refresco proactivo
|
|
24548
|
+
this.startRefreshTimer();
|
|
24549
|
+
}
|
|
24550
|
+
else if (storedState.refreshToken) {
|
|
24551
|
+
// 4. Token expirado pero hay refresh token - intentar refrescar
|
|
24552
|
+
try {
|
|
24553
|
+
await firstValueFrom(this.refreshAccessToken());
|
|
24554
|
+
}
|
|
24555
|
+
catch {
|
|
24556
|
+
this.clearState();
|
|
24557
|
+
}
|
|
24558
|
+
}
|
|
24559
|
+
else {
|
|
24560
|
+
this.clearState();
|
|
24561
|
+
}
|
|
24562
|
+
}
|
|
24563
|
+
// 5. Iniciar sincronización entre pestañas
|
|
24564
|
+
if (this.config.enableTabSync) {
|
|
24565
|
+
this.syncService.start();
|
|
24566
|
+
this.syncSubscription = this.syncService.onEvent$.subscribe(event => this.handleSyncEvent(event));
|
|
24567
|
+
}
|
|
24568
|
+
this.stateService.setLoading(false);
|
|
24569
|
+
}
|
|
24570
|
+
ngOnDestroy() {
|
|
24571
|
+
this.stopRefreshTimer();
|
|
24572
|
+
this.syncSubscription?.unsubscribe();
|
|
24573
|
+
}
|
|
24574
|
+
// =============================================
|
|
24575
|
+
// AUTENTICACIÓN
|
|
24576
|
+
// =============================================
|
|
24577
|
+
/**
|
|
24578
|
+
* Inicia sesión con email y contraseña.
|
|
24579
|
+
*/
|
|
24580
|
+
signin(request) {
|
|
24581
|
+
this.stateService.clearError();
|
|
24582
|
+
return this.http.post(`${this.baseUrl}/signin`, request).pipe(tap(response => {
|
|
24583
|
+
if (response.mfaRequired) {
|
|
24584
|
+
// MFA requerido - guardar estado temporal
|
|
24585
|
+
this.stateService.setMFAPending({
|
|
24586
|
+
required: true,
|
|
24587
|
+
mfaToken: response.mfaToken,
|
|
24588
|
+
method: response.mfaMethod,
|
|
24589
|
+
});
|
|
24590
|
+
}
|
|
24591
|
+
else if (response.accessToken) {
|
|
24592
|
+
// Login exitoso sin MFA
|
|
24593
|
+
this.handleSuccessfulAuth(response);
|
|
24594
|
+
}
|
|
24595
|
+
}), catchError(error => this.handleAuthError(error)));
|
|
24596
|
+
}
|
|
24597
|
+
/**
|
|
24598
|
+
* Registra un nuevo usuario.
|
|
24599
|
+
* El usuario queda en estado PENDING hasta verificar su email.
|
|
24600
|
+
*/
|
|
24601
|
+
signup(request) {
|
|
24602
|
+
this.stateService.clearError();
|
|
24603
|
+
return this.http
|
|
24604
|
+
.post(`${this.baseUrl}/signup`, request)
|
|
24605
|
+
.pipe(catchError(error => this.handleAuthError(error)));
|
|
24606
|
+
}
|
|
24607
|
+
/**
|
|
24608
|
+
* Verifica email con código de 6 dígitos.
|
|
24609
|
+
* Si es exitoso, hace auto-login y retorna tokens.
|
|
24610
|
+
*/
|
|
24611
|
+
verifyEmail(request) {
|
|
24612
|
+
this.stateService.clearError();
|
|
24613
|
+
return this.http.post(`${this.baseUrl}/verify-email`, request).pipe(tap(response => {
|
|
24614
|
+
if (response.verified && response.accessToken) {
|
|
24615
|
+
// Auto-login: guardar tokens y actualizar estado
|
|
24616
|
+
this.handleSuccessfulAuth(response);
|
|
24617
|
+
}
|
|
24618
|
+
}), catchError(error => this.handleAuthError(error)));
|
|
24619
|
+
}
|
|
24620
|
+
/**
|
|
24621
|
+
* Reenvía código de verificación al email.
|
|
24622
|
+
*/
|
|
24623
|
+
resendCode(request) {
|
|
24624
|
+
return this.http
|
|
24625
|
+
.post(`${this.baseUrl}/resend-code`, request)
|
|
24626
|
+
.pipe(catchError(error => this.handleAuthError(error)));
|
|
24627
|
+
}
|
|
24628
|
+
/**
|
|
24629
|
+
* Verifica código MFA.
|
|
24630
|
+
*/
|
|
24631
|
+
verifyMFA(code) {
|
|
24632
|
+
const mfaState = this.mfaPending();
|
|
24633
|
+
if (!mfaState.mfaToken) {
|
|
24634
|
+
return throwError(() => ({
|
|
24635
|
+
code: 'MFA_NOT_PENDING',
|
|
24636
|
+
message: 'No hay verificación MFA pendiente',
|
|
24637
|
+
}));
|
|
24638
|
+
}
|
|
24639
|
+
return this.http
|
|
24640
|
+
.post(`${this.baseUrl}/mfa/verify`, {
|
|
24641
|
+
mfaToken: mfaState.mfaToken,
|
|
24642
|
+
code,
|
|
24643
|
+
})
|
|
24644
|
+
.pipe(tap(response => {
|
|
24645
|
+
this.stateService.clearMFAPending();
|
|
24646
|
+
this.handleSuccessfulAuth(response);
|
|
24647
|
+
}), catchError(error => this.handleAuthError(error)));
|
|
24648
|
+
}
|
|
24649
|
+
/**
|
|
24650
|
+
* Refresca el token de acceso.
|
|
24651
|
+
*/
|
|
24652
|
+
refreshAccessToken() {
|
|
24653
|
+
const refreshToken = this.state().refreshToken;
|
|
24654
|
+
if (!refreshToken) {
|
|
24655
|
+
return throwError(() => ({
|
|
24656
|
+
code: 'NO_REFRESH_TOKEN',
|
|
24657
|
+
message: 'No hay token de refresco',
|
|
24658
|
+
}));
|
|
24659
|
+
}
|
|
24660
|
+
return this.http.post(`${this.baseUrl}/refresh`, { refreshToken }).pipe(tap(response => {
|
|
24661
|
+
const expiresAt = Date.now() + response.expiresIn * 1000;
|
|
24662
|
+
this.stateService.updateAccessToken(response.accessToken, response.expiresIn);
|
|
24663
|
+
this.storageService.saveAccessToken(response.accessToken, expiresAt);
|
|
24664
|
+
this.startRefreshTimer();
|
|
24665
|
+
this.syncService.broadcast({
|
|
24666
|
+
type: 'TOKEN_REFRESH',
|
|
24667
|
+
payload: { accessToken: response.accessToken, expiresAt },
|
|
24668
|
+
});
|
|
24669
|
+
}), catchError(error => {
|
|
24670
|
+
this.logout();
|
|
24671
|
+
return throwError(() => error);
|
|
24672
|
+
}));
|
|
24673
|
+
}
|
|
24674
|
+
/**
|
|
24675
|
+
* Cierra sesión.
|
|
24676
|
+
*/
|
|
24677
|
+
logout() {
|
|
24678
|
+
const refreshToken = this.state().refreshToken;
|
|
24679
|
+
// Notificar al backend (fire and forget)
|
|
24680
|
+
if (refreshToken) {
|
|
24681
|
+
this.http
|
|
24682
|
+
.post(`${this.baseUrl}/logout`, { refreshToken })
|
|
24683
|
+
.pipe(catchError(() => of(null)))
|
|
24684
|
+
.subscribe();
|
|
24685
|
+
}
|
|
24686
|
+
// Cerrar sesión de Firebase si está integrado
|
|
24687
|
+
this.signOutFirebase();
|
|
24688
|
+
this.clearState();
|
|
24689
|
+
this.syncService.broadcast({ type: 'LOGOUT' });
|
|
24690
|
+
this.router.navigate([this.config.loginRoute]);
|
|
24691
|
+
}
|
|
24692
|
+
// =============================================
|
|
24693
|
+
// MFA SETUP (usuario autenticado)
|
|
24694
|
+
// =============================================
|
|
24695
|
+
/**
|
|
24696
|
+
* Configura MFA para el usuario.
|
|
24697
|
+
*/
|
|
24698
|
+
setupMFA(method, phone) {
|
|
24699
|
+
return this.http
|
|
24700
|
+
.post(`${this.baseUrl}/mfa/setup`, { method, phone })
|
|
24701
|
+
.pipe(catchError(error => this.handleAuthError(error)));
|
|
24702
|
+
}
|
|
24703
|
+
/**
|
|
24704
|
+
* Confirma la configuración de MFA.
|
|
24705
|
+
*/
|
|
24706
|
+
confirmMFA(code) {
|
|
24707
|
+
return this.http
|
|
24708
|
+
.post(`${this.baseUrl}/mfa/confirm`, { code })
|
|
24709
|
+
.pipe(catchError(error => this.handleAuthError(error)));
|
|
24710
|
+
}
|
|
24711
|
+
/**
|
|
24712
|
+
* Deshabilita MFA.
|
|
24713
|
+
*/
|
|
24714
|
+
disableMFA(password) {
|
|
24715
|
+
return this.http
|
|
24716
|
+
.post(`${this.baseUrl}/mfa/disable`, { password })
|
|
24717
|
+
.pipe(catchError(error => this.handleAuthError(error)));
|
|
24718
|
+
}
|
|
24719
|
+
// =============================================
|
|
24720
|
+
// PERMISOS
|
|
24721
|
+
// =============================================
|
|
24722
|
+
/**
|
|
24723
|
+
* Obtiene los permisos actualizados del backend.
|
|
24724
|
+
*/
|
|
24725
|
+
fetchPermissions() {
|
|
24726
|
+
return this.http.get(`${this.baseUrl}/permissions`).pipe(tap(response => {
|
|
24727
|
+
this.stateService.updatePermissions(response.roles, response.permissions, response.isSuperAdmin);
|
|
24728
|
+
this.storageService.savePermissions(response);
|
|
24729
|
+
this.syncService.broadcast({ type: 'PERMISSIONS_UPDATE' });
|
|
24730
|
+
}), catchError(error => this.handleAuthError(error)));
|
|
24731
|
+
}
|
|
24732
|
+
/**
|
|
24733
|
+
* Verifica si el usuario tiene un permiso específico.
|
|
24734
|
+
* Formato: "resource:action" (ej: "templates:edit")
|
|
24735
|
+
*/
|
|
24736
|
+
hasPermission(permission) {
|
|
24737
|
+
if (this.isSuperAdmin())
|
|
24738
|
+
return true;
|
|
24739
|
+
const [resource, action] = permission.split(':');
|
|
24740
|
+
return this.permissions().some(p => {
|
|
24741
|
+
const [pResource, pAction] = p.split(':');
|
|
24742
|
+
return ((pResource === '*' || pResource === resource) && (pAction === '*' || pAction === action));
|
|
24743
|
+
});
|
|
24744
|
+
}
|
|
24745
|
+
/**
|
|
24746
|
+
* Verifica si el usuario tiene alguno de los permisos dados.
|
|
24747
|
+
*/
|
|
24748
|
+
hasAnyPermission(permissions) {
|
|
24749
|
+
return permissions.some(p => this.hasPermission(p));
|
|
24750
|
+
}
|
|
24751
|
+
/**
|
|
24752
|
+
* Verifica si el usuario tiene todos los permisos dados.
|
|
24753
|
+
*/
|
|
24754
|
+
hasAllPermissions(permissions) {
|
|
24755
|
+
return permissions.every(p => this.hasPermission(p));
|
|
24756
|
+
}
|
|
24757
|
+
/**
|
|
24758
|
+
* Verifica si el usuario tiene un rol específico.
|
|
24759
|
+
*/
|
|
24760
|
+
hasRole(role) {
|
|
24761
|
+
return this.roles().some(r => r.toLowerCase() === role.toLowerCase());
|
|
24762
|
+
}
|
|
24763
|
+
// =============================================
|
|
24764
|
+
// PRIVATE METHODS
|
|
24765
|
+
// =============================================
|
|
24766
|
+
get baseUrl() {
|
|
24767
|
+
return `${this.config.apiUrl}${this.config.authPrefix}`;
|
|
24768
|
+
}
|
|
24769
|
+
handleSuccessfulAuth(response) {
|
|
24770
|
+
const expiresAt = Date.now() + response.expiresIn * 1000;
|
|
24771
|
+
const tokenData = this.tokenService.parseToken(response.accessToken);
|
|
24772
|
+
this.stateService.setAuthenticated({
|
|
24773
|
+
accessToken: response.accessToken,
|
|
24774
|
+
refreshToken: response.refreshToken,
|
|
24775
|
+
userId: tokenData?.uid,
|
|
24776
|
+
email: tokenData?.email,
|
|
24777
|
+
roles: response.roles || [],
|
|
24778
|
+
permissions: response.permissions || [],
|
|
24779
|
+
isSuperAdmin: response.permissions?.includes('*:*') || false,
|
|
24780
|
+
expiresAt,
|
|
24781
|
+
});
|
|
24782
|
+
this.storageService.saveState({
|
|
24783
|
+
accessToken: response.accessToken,
|
|
24784
|
+
refreshToken: response.refreshToken,
|
|
24785
|
+
roles: response.roles || [],
|
|
24786
|
+
permissions: response.permissions || [],
|
|
24787
|
+
isSuperAdmin: response.permissions?.includes('*:*') || false,
|
|
24788
|
+
expiresAt,
|
|
24789
|
+
});
|
|
24790
|
+
this.startRefreshTimer();
|
|
24791
|
+
this.syncService.broadcast({ type: 'LOGIN' });
|
|
24792
|
+
// Integración con Firebase
|
|
24793
|
+
if (this.config.enableFirebaseIntegration &&
|
|
24794
|
+
'firebaseToken' in response &&
|
|
24795
|
+
response.firebaseToken) {
|
|
24796
|
+
this.signInWithFirebase(response.firebaseToken);
|
|
24797
|
+
}
|
|
24798
|
+
}
|
|
24799
|
+
clearState() {
|
|
24800
|
+
this.stopRefreshTimer();
|
|
24801
|
+
this.stateService.reset();
|
|
24802
|
+
this.storageService.clear();
|
|
24803
|
+
}
|
|
24804
|
+
startRefreshTimer() {
|
|
24805
|
+
this.stopRefreshTimer();
|
|
24806
|
+
const state = this.stateService.state();
|
|
24807
|
+
if (!state.expiresAt)
|
|
24808
|
+
return;
|
|
24809
|
+
const refreshBeforeMs = (this.config.refreshBeforeExpiry || 60) * 1000;
|
|
24810
|
+
const refreshAt = state.expiresAt - refreshBeforeMs;
|
|
24811
|
+
const delay = refreshAt - Date.now();
|
|
24812
|
+
if (delay > 0) {
|
|
24813
|
+
this.refreshTimerId = setTimeout(() => {
|
|
24814
|
+
this.refreshAccessToken().subscribe({
|
|
24815
|
+
error: () => this.logout(),
|
|
24816
|
+
});
|
|
24817
|
+
}, delay);
|
|
24818
|
+
}
|
|
24819
|
+
else if (state.refreshToken) {
|
|
24820
|
+
// Token ya debería refrescarse, intentar ahora
|
|
24821
|
+
this.refreshAccessToken().subscribe({
|
|
24822
|
+
error: () => this.logout(),
|
|
24823
|
+
});
|
|
24824
|
+
}
|
|
24825
|
+
}
|
|
24826
|
+
stopRefreshTimer() {
|
|
24827
|
+
if (this.refreshTimerId) {
|
|
24828
|
+
clearTimeout(this.refreshTimerId);
|
|
24829
|
+
this.refreshTimerId = null;
|
|
24830
|
+
}
|
|
24831
|
+
}
|
|
24832
|
+
handleSyncEvent(event) {
|
|
24833
|
+
switch (event.type) {
|
|
24834
|
+
case 'LOGIN':
|
|
24835
|
+
case 'TOKEN_REFRESH': {
|
|
24836
|
+
// Recargar estado desde storage
|
|
24837
|
+
const state = this.storageService.loadState();
|
|
24838
|
+
if (state.accessToken) {
|
|
24839
|
+
this.stateService.restoreFromStorage(state);
|
|
24840
|
+
const claims = this.tokenService.parseToken(state.accessToken);
|
|
24841
|
+
if (claims) {
|
|
24842
|
+
this.stateService.updateUserInfo(claims.uid, claims.email);
|
|
24843
|
+
}
|
|
24844
|
+
this.startRefreshTimer();
|
|
24845
|
+
}
|
|
24846
|
+
break;
|
|
24847
|
+
}
|
|
24848
|
+
case 'LOGOUT':
|
|
24849
|
+
this.stateService.reset();
|
|
24850
|
+
this.stopRefreshTimer();
|
|
24851
|
+
this.router.navigate([this.config.loginRoute]);
|
|
24852
|
+
break;
|
|
24853
|
+
case 'PERMISSIONS_UPDATE': {
|
|
24854
|
+
const perms = this.storageService.loadPermissions();
|
|
24855
|
+
this.stateService.updatePermissions(perms.roles, perms.permissions, perms.isSuperAdmin);
|
|
24856
|
+
break;
|
|
24857
|
+
}
|
|
24858
|
+
}
|
|
24859
|
+
}
|
|
24860
|
+
handleAuthError(error) {
|
|
24861
|
+
const authError = {
|
|
24862
|
+
code: error.error?.code || 'UNKNOWN_ERROR',
|
|
24863
|
+
message: error.error?.message || 'Error de autenticación desconocido',
|
|
24864
|
+
};
|
|
24865
|
+
this.stateService.setError(authError);
|
|
24866
|
+
return throwError(() => authError);
|
|
24867
|
+
}
|
|
24868
|
+
// =============================================
|
|
24869
|
+
// FIREBASE INTEGRATION
|
|
24870
|
+
// =============================================
|
|
24871
|
+
async signInWithFirebase(firebaseToken) {
|
|
24872
|
+
try {
|
|
24873
|
+
if (this.firebaseService) {
|
|
24874
|
+
await this.firebaseService.signInWithCustomToken(firebaseToken);
|
|
24875
|
+
console.log('[ValtechAuth] Firebase signin successful');
|
|
24876
|
+
}
|
|
24877
|
+
else {
|
|
24878
|
+
console.warn('[ValtechAuth] FirebaseService not provided. Add provideValtechFirebase() to your providers.');
|
|
24879
|
+
}
|
|
24880
|
+
}
|
|
24881
|
+
catch (error) {
|
|
24882
|
+
// No bloquear el login principal si Firebase falla
|
|
24883
|
+
console.error('[ValtechAuth] Firebase signin failed:', error);
|
|
24884
|
+
}
|
|
24885
|
+
}
|
|
24886
|
+
async signOutFirebase() {
|
|
24887
|
+
if (!this.config.enableFirebaseIntegration)
|
|
24888
|
+
return;
|
|
24889
|
+
try {
|
|
24890
|
+
if (this.firebaseService) {
|
|
24891
|
+
await this.firebaseService.signOut();
|
|
24892
|
+
console.log('[ValtechAuth] Firebase signout successful');
|
|
24893
|
+
}
|
|
24894
|
+
}
|
|
24895
|
+
catch (error) {
|
|
24896
|
+
// Ignorar errores de Firebase signout
|
|
24897
|
+
console.warn('[ValtechAuth] Firebase signout failed:', error);
|
|
24898
|
+
}
|
|
24899
|
+
}
|
|
24900
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, deps: [{ token: VALTECH_AUTH_CONFIG }, { token: i1$9.HttpClient }, { token: i1$1.Router }, { token: AuthStateService }, { token: TokenService }, { token: AuthStorageService }, { token: AuthSyncService }, { token: FirebaseService }], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
24901
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, providedIn: 'root' }); }
|
|
24902
|
+
}
|
|
24903
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AuthService, decorators: [{
|
|
24904
|
+
type: Injectable,
|
|
24905
|
+
args: [{ providedIn: 'root' }]
|
|
24906
|
+
}], ctorParameters: () => [{ type: undefined, decorators: [{
|
|
24907
|
+
type: Inject,
|
|
24908
|
+
args: [VALTECH_AUTH_CONFIG]
|
|
24909
|
+
}] }, { type: i1$9.HttpClient }, { type: i1$1.Router }, { type: AuthStateService }, { type: TokenService }, { type: AuthStorageService }, { type: AuthSyncService }, { type: FirebaseService }] });
|
|
24910
|
+
|
|
24911
|
+
// Control de estado de refresco (singleton a nivel de módulo)
|
|
24912
|
+
let isRefreshing = false;
|
|
24913
|
+
const refreshTokenSubject = new BehaviorSubject(null);
|
|
24914
|
+
/**
|
|
24915
|
+
* Interceptor HTTP que:
|
|
24916
|
+
* 1. Agrega header Authorization con Bearer token a requests API
|
|
24917
|
+
* 2. Maneja errores 401 refrescando el token automáticamente
|
|
24918
|
+
* 3. Encola requests durante el refresco para evitar múltiples refresh
|
|
24919
|
+
*
|
|
24920
|
+
* @example
|
|
24921
|
+
* ```typescript
|
|
24922
|
+
* // Incluido automáticamente por provideValtechAuth()
|
|
24923
|
+
* // Para uso manual:
|
|
24924
|
+
* import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
24925
|
+
* import { authInterceptor } from 'valtech-components';
|
|
24926
|
+
*
|
|
24927
|
+
* bootstrapApplication(AppComponent, {
|
|
24928
|
+
* providers: [
|
|
24929
|
+
* provideHttpClient(withInterceptors([authInterceptor])),
|
|
24930
|
+
* ],
|
|
24931
|
+
* });
|
|
24932
|
+
* ```
|
|
24933
|
+
*/
|
|
24934
|
+
const authInterceptor = (request, next) => {
|
|
24935
|
+
const authService = inject(AuthService);
|
|
24936
|
+
const config = inject(VALTECH_AUTH_CONFIG);
|
|
24937
|
+
// Omitir requests que no son a nuestra API
|
|
24938
|
+
if (!isApiRequest(request, config.apiUrl)) {
|
|
24939
|
+
return next(request);
|
|
24940
|
+
}
|
|
24941
|
+
// Omitir endpoints de auth que no necesitan token
|
|
24942
|
+
if (isAuthEndpoint(request, config.authPrefix)) {
|
|
24943
|
+
return next(request);
|
|
24944
|
+
}
|
|
24945
|
+
const accessToken = authService.accessToken();
|
|
24946
|
+
// Agregar header de autorización si hay token
|
|
24947
|
+
if (accessToken) {
|
|
24948
|
+
request = addAuthHeader(request, accessToken);
|
|
24949
|
+
}
|
|
24950
|
+
return next(request).pipe(catchError((error) => {
|
|
24951
|
+
if (error.status === 401 && !isAuthEndpoint(request, config.authPrefix)) {
|
|
24952
|
+
return handle401Error(request, next, authService);
|
|
24953
|
+
}
|
|
24954
|
+
if (error.status === 403) {
|
|
24955
|
+
console.error('[ValtechAuth] Permiso denegado:', error.error?.message || 'Acceso prohibido');
|
|
24956
|
+
}
|
|
24957
|
+
return throwError(() => error);
|
|
24958
|
+
}));
|
|
24959
|
+
};
|
|
24960
|
+
/**
|
|
24961
|
+
* Agrega header de autorización a la request.
|
|
24962
|
+
*/
|
|
24963
|
+
function addAuthHeader(request, token) {
|
|
24964
|
+
return request.clone({
|
|
24965
|
+
setHeaders: {
|
|
24966
|
+
Authorization: `Bearer ${token}`,
|
|
24967
|
+
},
|
|
24968
|
+
});
|
|
24969
|
+
}
|
|
24970
|
+
/**
|
|
24971
|
+
* Verifica si la request es a nuestra API.
|
|
24972
|
+
*/
|
|
24973
|
+
function isApiRequest(request, apiUrl) {
|
|
24974
|
+
return request.url.startsWith(apiUrl) || request.url.includes('/v2/auth');
|
|
24975
|
+
}
|
|
24976
|
+
/**
|
|
24977
|
+
* Verifica si la request es a un endpoint de auth que no debe reintentar.
|
|
24978
|
+
*/
|
|
24979
|
+
function isAuthEndpoint(request, authPrefix) {
|
|
24980
|
+
const authEndpoints = ['/signin', '/signup', '/refresh', '/logout', '/mfa/verify'];
|
|
24981
|
+
return authEndpoints.some((endpoint) => request.url.includes(`${authPrefix}${endpoint}`));
|
|
24982
|
+
}
|
|
24983
|
+
/**
|
|
24984
|
+
* Maneja errores 401 refrescando el token.
|
|
24985
|
+
*/
|
|
24986
|
+
function handle401Error(request, next, authService) {
|
|
24987
|
+
if (!isRefreshing) {
|
|
24988
|
+
isRefreshing = true;
|
|
24989
|
+
refreshTokenSubject.next(null);
|
|
24990
|
+
return authService.refreshAccessToken().pipe(switchMap((response) => {
|
|
24991
|
+
refreshTokenSubject.next(response.accessToken);
|
|
24992
|
+
return next(addAuthHeader(request, response.accessToken));
|
|
24993
|
+
}), catchError((error) => {
|
|
24994
|
+
authService.logout();
|
|
24995
|
+
return throwError(() => error);
|
|
24996
|
+
}), finalize(() => {
|
|
24997
|
+
isRefreshing = false;
|
|
24998
|
+
}));
|
|
24999
|
+
}
|
|
25000
|
+
// Esperar a que termine el refresco en curso
|
|
25001
|
+
return refreshTokenSubject.pipe(filter$1((token) => token !== null), take(1), switchMap((token) => next(addAuthHeader(request, token))));
|
|
25002
|
+
}
|
|
25003
|
+
|
|
25004
|
+
/**
|
|
25005
|
+
* Token de inyección para la configuración de Auth.
|
|
25006
|
+
*/
|
|
25007
|
+
const VALTECH_AUTH_CONFIG = new InjectionToken('ValtechAuthConfig');
|
|
25008
|
+
/**
|
|
25009
|
+
* Configuración por defecto.
|
|
25010
|
+
*/
|
|
25011
|
+
const DEFAULT_AUTH_CONFIG = {
|
|
25012
|
+
authPrefix: '/v2/auth',
|
|
25013
|
+
storagePrefix: 'valtech_auth_',
|
|
25014
|
+
refreshBeforeExpiry: 60,
|
|
25015
|
+
enableTabSync: true,
|
|
25016
|
+
loginRoute: '/login',
|
|
25017
|
+
homeRoute: '/',
|
|
25018
|
+
unauthorizedRoute: '/unauthorized',
|
|
25019
|
+
enableFirebaseIntegration: false,
|
|
25020
|
+
};
|
|
25021
|
+
/**
|
|
25022
|
+
* Factory para inicializar el AuthService.
|
|
25023
|
+
*/
|
|
25024
|
+
function initializeAuth(authService) {
|
|
25025
|
+
return () => authService.initialize();
|
|
25026
|
+
}
|
|
25027
|
+
/**
|
|
25028
|
+
* Provee el servicio de autenticación a la aplicación Angular.
|
|
25029
|
+
*
|
|
25030
|
+
* @param config - Configuración de autenticación
|
|
25031
|
+
* @returns EnvironmentProviders para usar en bootstrapApplication
|
|
25032
|
+
*
|
|
25033
|
+
* @example
|
|
25034
|
+
* ```typescript
|
|
25035
|
+
* // main.ts
|
|
25036
|
+
* import { bootstrapApplication } from '@angular/platform-browser';
|
|
25037
|
+
* import { provideValtechAuth } from 'valtech-components';
|
|
25038
|
+
* import { environment } from './environments/environment';
|
|
25039
|
+
*
|
|
25040
|
+
* bootstrapApplication(AppComponent, {
|
|
25041
|
+
* providers: [
|
|
25042
|
+
* provideValtechAuth({
|
|
25043
|
+
* apiUrl: environment.apiUrl,
|
|
25044
|
+
* enableFirebaseIntegration: true,
|
|
25045
|
+
* }),
|
|
25046
|
+
* ],
|
|
25047
|
+
* });
|
|
25048
|
+
* ```
|
|
25049
|
+
*/
|
|
25050
|
+
function provideValtechAuth(config) {
|
|
25051
|
+
const mergedConfig = {
|
|
25052
|
+
...DEFAULT_AUTH_CONFIG,
|
|
25053
|
+
...config,
|
|
25054
|
+
};
|
|
25055
|
+
return makeEnvironmentProviders([
|
|
25056
|
+
{ provide: VALTECH_AUTH_CONFIG, useValue: mergedConfig },
|
|
25057
|
+
provideHttpClient(withInterceptors([authInterceptor])),
|
|
25058
|
+
// Inicializar AuthService al arrancar la app
|
|
25059
|
+
{
|
|
25060
|
+
provide: APP_INITIALIZER,
|
|
25061
|
+
useFactory: initializeAuth,
|
|
25062
|
+
deps: [AuthService],
|
|
25063
|
+
multi: true,
|
|
25064
|
+
},
|
|
25065
|
+
]);
|
|
25066
|
+
}
|
|
25067
|
+
/**
|
|
25068
|
+
* Provee solo el interceptor (para apps que ya tienen AuthService configurado manualmente).
|
|
25069
|
+
*/
|
|
25070
|
+
function provideValtechAuthInterceptor() {
|
|
25071
|
+
return makeEnvironmentProviders([
|
|
25072
|
+
provideHttpClient(withInterceptors([authInterceptor])),
|
|
25073
|
+
]);
|
|
25074
|
+
}
|
|
25075
|
+
|
|
25076
|
+
/**
|
|
25077
|
+
* Guard que verifica si el usuario está autenticado.
|
|
25078
|
+
* Redirige a loginRoute si no está autenticado.
|
|
25079
|
+
*
|
|
25080
|
+
* @example
|
|
25081
|
+
* ```typescript
|
|
25082
|
+
* import { authGuard } from 'valtech-components';
|
|
25083
|
+
*
|
|
25084
|
+
* const routes: Routes = [
|
|
25085
|
+
* {
|
|
25086
|
+
* path: 'dashboard',
|
|
25087
|
+
* canActivate: [authGuard],
|
|
25088
|
+
* loadComponent: () => import('./dashboard.page'),
|
|
25089
|
+
* },
|
|
25090
|
+
* ];
|
|
25091
|
+
* ```
|
|
25092
|
+
*/
|
|
25093
|
+
const authGuard = () => {
|
|
25094
|
+
const authService = inject(AuthService);
|
|
25095
|
+
const router = inject(Router);
|
|
25096
|
+
const config = inject(VALTECH_AUTH_CONFIG);
|
|
25097
|
+
if (authService.isAuthenticated()) {
|
|
25098
|
+
return true;
|
|
25099
|
+
}
|
|
25100
|
+
return router.createUrlTree([config.loginRoute]);
|
|
25101
|
+
};
|
|
25102
|
+
/**
|
|
25103
|
+
* Guard que verifica si el usuario NO está autenticado.
|
|
25104
|
+
* Redirige a homeRoute si ya está autenticado.
|
|
25105
|
+
* Útil para páginas de login/registro.
|
|
25106
|
+
*
|
|
25107
|
+
* @example
|
|
25108
|
+
* ```typescript
|
|
25109
|
+
* import { guestGuard } from 'valtech-components';
|
|
25110
|
+
*
|
|
25111
|
+
* const routes: Routes = [
|
|
25112
|
+
* {
|
|
25113
|
+
* path: 'login',
|
|
25114
|
+
* canActivate: [guestGuard],
|
|
25115
|
+
* loadComponent: () => import('./login.page'),
|
|
25116
|
+
* },
|
|
25117
|
+
* ];
|
|
25118
|
+
* ```
|
|
25119
|
+
*/
|
|
25120
|
+
const guestGuard = () => {
|
|
25121
|
+
const authService = inject(AuthService);
|
|
25122
|
+
const router = inject(Router);
|
|
25123
|
+
const config = inject(VALTECH_AUTH_CONFIG);
|
|
25124
|
+
if (!authService.isAuthenticated()) {
|
|
25125
|
+
return true;
|
|
25126
|
+
}
|
|
25127
|
+
return router.createUrlTree([config.homeRoute]);
|
|
25128
|
+
};
|
|
25129
|
+
/**
|
|
25130
|
+
* Factory para crear guard de permisos.
|
|
25131
|
+
* Verifica si el usuario tiene el permiso especificado.
|
|
25132
|
+
*
|
|
25133
|
+
* @param permissions - Permiso o lista de permisos requeridos (OR)
|
|
25134
|
+
* @returns Guard function
|
|
25135
|
+
*
|
|
25136
|
+
* @example
|
|
25137
|
+
* ```typescript
|
|
25138
|
+
* import { authGuard, permissionGuard } from 'valtech-components';
|
|
25139
|
+
*
|
|
25140
|
+
* const routes: Routes = [
|
|
25141
|
+
* {
|
|
25142
|
+
* path: 'templates',
|
|
25143
|
+
* canActivate: [authGuard, permissionGuard('templates:read')],
|
|
25144
|
+
* loadComponent: () => import('./templates.page'),
|
|
25145
|
+
* },
|
|
25146
|
+
* {
|
|
25147
|
+
* path: 'admin',
|
|
25148
|
+
* canActivate: [authGuard, permissionGuard(['admin:*', 'super_admin'])],
|
|
25149
|
+
* loadComponent: () => import('./admin.page'),
|
|
25150
|
+
* },
|
|
25151
|
+
* ];
|
|
25152
|
+
* ```
|
|
25153
|
+
*/
|
|
25154
|
+
function permissionGuard(permissions) {
|
|
25155
|
+
return () => {
|
|
25156
|
+
const authService = inject(AuthService);
|
|
25157
|
+
const router = inject(Router);
|
|
25158
|
+
const config = inject(VALTECH_AUTH_CONFIG);
|
|
25159
|
+
const permArray = Array.isArray(permissions) ? permissions : [permissions];
|
|
25160
|
+
if (authService.hasAnyPermission(permArray)) {
|
|
25161
|
+
return true;
|
|
25162
|
+
}
|
|
25163
|
+
console.warn(`[ValtechAuth] Permiso denegado. Requerido: ${permArray.join(' o ')}`);
|
|
25164
|
+
return router.createUrlTree([config.unauthorizedRoute]);
|
|
25165
|
+
};
|
|
25166
|
+
}
|
|
25167
|
+
/**
|
|
25168
|
+
* Guard que lee permisos desde route.data.
|
|
25169
|
+
* Permite configurar permisos directamente en la definición de rutas.
|
|
25170
|
+
*
|
|
25171
|
+
* @example
|
|
25172
|
+
* ```typescript
|
|
25173
|
+
* import { authGuard, permissionGuardFromRoute } from 'valtech-components';
|
|
25174
|
+
*
|
|
25175
|
+
* const routes: Routes = [
|
|
25176
|
+
* {
|
|
25177
|
+
* path: 'admin/users',
|
|
25178
|
+
* canActivate: [authGuard, permissionGuardFromRoute],
|
|
25179
|
+
* data: {
|
|
25180
|
+
* permissions: ['users:read', 'users:manage'],
|
|
25181
|
+
* requireAll: false // true = AND, false = OR (default)
|
|
25182
|
+
* },
|
|
25183
|
+
* loadComponent: () => import('./users.page'),
|
|
25184
|
+
* },
|
|
25185
|
+
* ];
|
|
25186
|
+
* ```
|
|
25187
|
+
*/
|
|
25188
|
+
const permissionGuardFromRoute = (route) => {
|
|
25189
|
+
const authService = inject(AuthService);
|
|
25190
|
+
const router = inject(Router);
|
|
25191
|
+
const config = inject(VALTECH_AUTH_CONFIG);
|
|
25192
|
+
const permissions = route.data['permissions'];
|
|
25193
|
+
const requireAll = route.data['requireAll'];
|
|
25194
|
+
if (!permissions || permissions.length === 0) {
|
|
25195
|
+
return true;
|
|
25196
|
+
}
|
|
25197
|
+
const hasAccess = requireAll
|
|
25198
|
+
? authService.hasAllPermissions(permissions)
|
|
25199
|
+
: authService.hasAnyPermission(permissions);
|
|
25200
|
+
if (hasAccess) {
|
|
25201
|
+
return true;
|
|
25202
|
+
}
|
|
25203
|
+
console.warn(`[ValtechAuth] Permiso denegado. Requerido: ${permissions.join(requireAll ? ' y ' : ' o ')}`);
|
|
25204
|
+
return router.createUrlTree([config.unauthorizedRoute]);
|
|
25205
|
+
};
|
|
25206
|
+
/**
|
|
25207
|
+
* Guard que verifica si el usuario es super admin.
|
|
25208
|
+
*
|
|
25209
|
+
* @example
|
|
25210
|
+
* ```typescript
|
|
25211
|
+
* import { authGuard, superAdminGuard } from 'valtech-components';
|
|
25212
|
+
*
|
|
25213
|
+
* const routes: Routes = [
|
|
25214
|
+
* {
|
|
25215
|
+
* path: 'super-admin',
|
|
25216
|
+
* canActivate: [authGuard, superAdminGuard],
|
|
25217
|
+
* loadComponent: () => import('./super-admin.page'),
|
|
25218
|
+
* },
|
|
25219
|
+
* ];
|
|
25220
|
+
* ```
|
|
25221
|
+
*/
|
|
25222
|
+
const superAdminGuard = () => {
|
|
25223
|
+
const authService = inject(AuthService);
|
|
25224
|
+
const router = inject(Router);
|
|
25225
|
+
const config = inject(VALTECH_AUTH_CONFIG);
|
|
25226
|
+
if (authService.isSuperAdmin()) {
|
|
25227
|
+
return true;
|
|
25228
|
+
}
|
|
25229
|
+
console.warn('[ValtechAuth] Acceso de super admin requerido');
|
|
25230
|
+
return router.createUrlTree([config.unauthorizedRoute]);
|
|
25231
|
+
};
|
|
25232
|
+
/**
|
|
25233
|
+
* Guard que verifica si el usuario tiene un rol específico.
|
|
25234
|
+
*
|
|
25235
|
+
* @param roles - Rol o lista de roles requeridos (OR)
|
|
25236
|
+
* @returns Guard function
|
|
25237
|
+
*
|
|
25238
|
+
* @example
|
|
25239
|
+
* ```typescript
|
|
25240
|
+
* import { authGuard, roleGuard } from 'valtech-components';
|
|
25241
|
+
*
|
|
25242
|
+
* const routes: Routes = [
|
|
25243
|
+
* {
|
|
25244
|
+
* path: 'editor',
|
|
25245
|
+
* canActivate: [authGuard, roleGuard(['editor', 'admin'])],
|
|
25246
|
+
* loadComponent: () => import('./editor.page'),
|
|
25247
|
+
* },
|
|
25248
|
+
* ];
|
|
25249
|
+
* ```
|
|
25250
|
+
*/
|
|
25251
|
+
function roleGuard(roles) {
|
|
25252
|
+
return () => {
|
|
25253
|
+
const authService = inject(AuthService);
|
|
25254
|
+
const router = inject(Router);
|
|
25255
|
+
const config = inject(VALTECH_AUTH_CONFIG);
|
|
25256
|
+
const roleArray = Array.isArray(roles) ? roles : [roles];
|
|
25257
|
+
const hasRole = roleArray.some((role) => authService.hasRole(role));
|
|
25258
|
+
if (hasRole) {
|
|
25259
|
+
return true;
|
|
25260
|
+
}
|
|
25261
|
+
console.warn(`[ValtechAuth] Rol requerido: ${roleArray.join(' o ')}`);
|
|
25262
|
+
return router.createUrlTree([config.unauthorizedRoute]);
|
|
25263
|
+
};
|
|
25264
|
+
}
|
|
25265
|
+
|
|
25266
|
+
/**
|
|
25267
|
+
* Valtech Auth Service
|
|
25268
|
+
*
|
|
25269
|
+
* Servicio de autenticación reutilizable para aplicaciones Angular.
|
|
25270
|
+
* Proporciona autenticación con AuthV2, MFA, sincronización entre pestañas,
|
|
25271
|
+
* y refresh proactivo de tokens.
|
|
25272
|
+
*
|
|
25273
|
+
* @example
|
|
25274
|
+
* ```typescript
|
|
25275
|
+
* // En main.ts
|
|
25276
|
+
* import { bootstrapApplication } from '@angular/platform-browser';
|
|
25277
|
+
* import { provideValtechAuth } from 'valtech-components';
|
|
25278
|
+
* import { environment } from './environments/environment';
|
|
25279
|
+
*
|
|
25280
|
+
* bootstrapApplication(AppComponent, {
|
|
25281
|
+
* providers: [
|
|
25282
|
+
* provideValtechAuth({
|
|
25283
|
+
* apiUrl: environment.apiUrl,
|
|
25284
|
+
* enableFirebaseIntegration: true,
|
|
25285
|
+
* }),
|
|
25286
|
+
* ],
|
|
25287
|
+
* });
|
|
25288
|
+
*
|
|
25289
|
+
* // En app.routes.ts
|
|
25290
|
+
* import { authGuard, guestGuard, permissionGuard } from 'valtech-components';
|
|
25291
|
+
*
|
|
25292
|
+
* const routes: Routes = [
|
|
25293
|
+
* { path: 'login', canActivate: [guestGuard], loadComponent: () => import('./login.page') },
|
|
25294
|
+
* { path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('./dashboard.page') },
|
|
25295
|
+
* { path: 'admin', canActivate: [authGuard, permissionGuard('admin:*')], loadComponent: () => import('./admin.page') },
|
|
25296
|
+
* ];
|
|
25297
|
+
*
|
|
25298
|
+
* // En componentes
|
|
25299
|
+
* import { AuthService } from 'valtech-components';
|
|
25300
|
+
*
|
|
25301
|
+
* @Component({...})
|
|
25302
|
+
* export class LoginComponent {
|
|
25303
|
+
* private auth = inject(AuthService);
|
|
25304
|
+
*
|
|
25305
|
+
* async login() {
|
|
25306
|
+
* await firstValueFrom(this.auth.signin({ email, password }));
|
|
25307
|
+
* if (this.auth.mfaPending().required) {
|
|
25308
|
+
* // Mostrar UI de MFA
|
|
25309
|
+
* } else {
|
|
25310
|
+
* this.router.navigate(['/dashboard']);
|
|
25311
|
+
* }
|
|
25312
|
+
* }
|
|
25313
|
+
*
|
|
25314
|
+
* // En template: usar signals directamente
|
|
25315
|
+
* // {{ auth.user()?.email }}
|
|
25316
|
+
* // @if (auth.hasPermission('templates:edit')) { ... }
|
|
25317
|
+
* }
|
|
25318
|
+
* ```
|
|
25319
|
+
*/
|
|
25320
|
+
// Tipos
|
|
25321
|
+
|
|
21131
25322
|
/*
|
|
21132
25323
|
* Public API Surface of valtech-components
|
|
21133
25324
|
*/
|
|
@@ -21136,5 +25327,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
|
|
|
21136
25327
|
* Generated bundle index. Do not edit.
|
|
21137
25328
|
*/
|
|
21138
25329
|
|
|
21139
|
-
export { ARTICLE_SPACING, AccordionComponent, ActionHeaderComponent, ActionType, AlertBoxComponent, ArticleBuilder, ArticleComponent, AvatarComponent, BannerComponent, BaseDefault, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, CheckInputComponent, ChipGroupComponent, ClearDefault, ClearDefaultBlock, ClearDefaultFull, ClearDefaultRound, ClearDefaultRoundBlock, ClearDefaultRoundFull, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContentLoaderComponent, CountdownComponent, CurrencyInputComponent, DEFAULT_CANCEL_BUTTON, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_EMPTY_STATE, DEFAULT_LEGEND_LABELS, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAYMENT_STATUS_COLORS, DEFAULT_PAYMENT_STATUS_LABELS, DEFAULT_PLATFORMS, DEFAULT_STATUS_COLORS, DEFAULT_STATUS_LABELS, DEFAULT_WINNER_LABELS, DataTableComponent, DateInputComponent, DateRangeInputComponent, DisplayComponent, DividerComponent, DownloadService, EmailInputComponent, ExpandableTextComponent, FabComponent, FileInputComponent, FooterComponent, FooterLinksComponent, FormComponent, FormFooterComponent, FunHeaderComponent, GlowCardComponent, HeaderComponent, HintComponent, HorizontalScrollComponent, HourInputComponent, HrefComponent, Icon, IconComponent, IconService, ImageComponent, InAppBrowserService, InfoComponent, InputType, ItemListComponent, LanguageSelectorComponent, LayeredCardComponent, LayoutComponent, LinkComponent, LinkProcessorService, LinksAccordionComponent, LinksCakeComponent, LocalStorageService, LocaleService, MODAL_SIZES, MOTION, MenuComponent, ModalService, MultiSelectSearchComponent, NavigationService, NoContentComponent, NotesBoxComponent, NumberFromToComponent, NumberInputComponent, NumberStepperComponent, OutlineDefault, OutlineDefaultBlock, OutlineDefaultFull, OutlineDefaultRound, OutlineDefaultRoundBlock, OutlineDefaultRoundFull, PLATFORM_CONFIGS, PageContentComponent, PageTemplateComponent, PageWrapperComponent, PaginationComponent, ParticipantCardComponent, PasswordInputComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PriceTagComponent, PrimarySolidBlockButton, PrimarySolidBlockHrefButton, PrimarySolidBlockIconButton, PrimarySolidBlockIconHrefButton, PrimarySolidDefaultRoundButton, PrimarySolidDefaultRoundHrefButton, PrimarySolidDefaultRoundIconButton, PrimarySolidDefaultRoundIconHrefButton, PrimarySolidFullButton, PrimarySolidFullHrefButton, PrimarySolidFullIconButton, PrimarySolidFullIconHrefButton, PrimarySolidLargeRoundButton, PrimarySolidLargeRoundHrefButton, PrimarySolidLargeRoundIconButton, PrimarySolidLargeRoundIconHrefButton, PrimarySolidSmallRoundButton, PrimarySolidSmallRoundHrefButton, PrimarySolidSmallRoundIconButton, PrimarySolidSmallRoundIconHrefButton, ProcessLinksPipe, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QuoteBoxComponent, RadioInputComponent, RaffleStatusCardComponent, RangeInputComponent, RatingComponent, RecapCardComponent, RightsFooterComponent, SKELETON_PRESETS, SearchSelectorComponent, SearchbarComponent, SecondarySolidBlockButton, SecondarySolidBlockHrefButton, SecondarySolidBlockIconButton, SecondarySolidBlockIconHrefButton, SecondarySolidDefaultRoundButton, SecondarySolidDefaultRoundHrefButton, SecondarySolidDefaultRoundIconButton, SecondarySolidDefaultRoundIconHrefButton, SecondarySolidFullButton, SecondarySolidFullHrefButton, SecondarySolidFullIconButton, SecondarySolidFullIconHrefButton, SecondarySolidLargeRoundButton, SecondarySolidLargeRoundHrefButton, SecondarySolidLargeRoundIconButton, SecondarySolidLargeRoundIconHrefButton, SecondarySolidSmallRoundButton, SecondarySolidSmallRoundHrefButton, SecondarySolidSmallRoundIconButton, SecondarySolidSmallRoundIconHrefButton, SegmentControlComponent, SelectSearchComponent, ShareButtonsComponent, SimpleComponent, SkeletonComponent, SolidBlockButton, SolidDefault, SolidDefaultBlock, SolidDefaultButton, SolidDefaultFull, SolidDefaultRound, SolidDefaultRoundBlock, SolidDefaultRoundButton, SolidDefaultRoundFull, SolidFullButton, SolidLargeButton, SolidLargeRoundButton, SolidSmallButton, SolidSmallRoundButton, StatsCardComponent, StepperComponent, SwipeCarouselComponent, TabbedContentComponent, TabsComponent, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TicketGridComponent, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, ToolbarActionType, ToolbarComponent, WinnerDisplayComponent, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, createGlowCardProps, createNumberFromToField, createTitleProps, goToTop, isAtEnd, maxLength, replaceSpecialChars, resolveColor, resolveInputDefaultValue };
|
|
25330
|
+
export { APP_IDS, ARTICLE_SPACING, AccordionComponent, ActionHeaderComponent, ActionType, AlertBoxComponent, ArticleBuilder, ArticleComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, BannerComponent, BaseDefault, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, CheckInputComponent, ChipGroupComponent, ClearDefault, ClearDefaultBlock, ClearDefaultFull, ClearDefaultRound, ClearDefaultRoundBlock, ClearDefaultRoundFull, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContentLoaderComponent, CountdownComponent, CurrencyInputComponent, DEFAULT_AUTH_CONFIG, DEFAULT_CANCEL_BUTTON, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_EMPTY_STATE, DEFAULT_LEGEND_LABELS, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PAYMENT_STATUS_COLORS, DEFAULT_PAYMENT_STATUS_LABELS, DEFAULT_PLATFORMS, DEFAULT_STATUS_COLORS, DEFAULT_STATUS_LABELS, DEFAULT_WINNER_LABELS, DataTableComponent, DateInputComponent, DateRangeInputComponent, DisplayComponent, DividerComponent, DownloadService, EmailInputComponent, ExpandableTextComponent, FIREBASE_PROJECTS, FabComponent, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FooterComponent, FooterLinksComponent, FormComponent, FormFooterComponent, FunHeaderComponent, GlowCardComponent, HeaderComponent, HintComponent, HorizontalScrollComponent, HourInputComponent, HrefComponent, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, Icon, IconComponent, IconService, ImageComponent, InAppBrowserService, InfoComponent, InputType, ItemListComponent, LanguageSelectorComponent, LayeredCardComponent, LayoutComponent, LinkComponent, LinkProcessorService, LinksAccordionComponent, LinksCakeComponent, LocalStorageService, LocaleService, MODAL_SIZES, MOTION, MenuComponent, MessagingService, ModalService, MultiSelectSearchComponent, NavigationService, NoContentComponent, NotesBoxComponent, NumberFromToComponent, NumberInputComponent, NumberStepperComponent, OutlineDefault, OutlineDefaultBlock, OutlineDefaultFull, OutlineDefaultRound, OutlineDefaultRoundBlock, OutlineDefaultRoundFull, PLATFORM_CONFIGS, PageContentComponent, PageTemplateComponent, PageWrapperComponent, PaginationComponent, ParticipantCardComponent, PasswordInputComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PriceTagComponent, PrimarySolidBlockButton, PrimarySolidBlockHrefButton, PrimarySolidBlockIconButton, PrimarySolidBlockIconHrefButton, PrimarySolidDefaultRoundButton, PrimarySolidDefaultRoundHrefButton, PrimarySolidDefaultRoundIconButton, PrimarySolidDefaultRoundIconHrefButton, PrimarySolidFullButton, PrimarySolidFullHrefButton, PrimarySolidFullIconButton, PrimarySolidFullIconHrefButton, PrimarySolidLargeRoundButton, PrimarySolidLargeRoundHrefButton, PrimarySolidLargeRoundIconButton, PrimarySolidLargeRoundIconHrefButton, PrimarySolidSmallRoundButton, PrimarySolidSmallRoundHrefButton, PrimarySolidSmallRoundIconButton, PrimarySolidSmallRoundIconHrefButton, ProcessLinksPipe, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QueryBuilder, QuoteBoxComponent, RadioInputComponent, RaffleStatusCardComponent, RangeInputComponent, RatingComponent, RecapCardComponent, RightsFooterComponent, SHARED_EMULATOR_CONFIG, SKELETON_PRESETS, SearchSelectorComponent, SearchbarComponent, SecondarySolidBlockButton, SecondarySolidBlockHrefButton, SecondarySolidBlockIconButton, SecondarySolidBlockIconHrefButton, SecondarySolidDefaultRoundButton, SecondarySolidDefaultRoundHrefButton, SecondarySolidDefaultRoundIconButton, SecondarySolidDefaultRoundIconHrefButton, SecondarySolidFullButton, SecondarySolidFullHrefButton, SecondarySolidFullIconButton, SecondarySolidFullIconHrefButton, SecondarySolidLargeRoundButton, SecondarySolidLargeRoundHrefButton, SecondarySolidLargeRoundIconButton, SecondarySolidLargeRoundIconHrefButton, SecondarySolidSmallRoundButton, SecondarySolidSmallRoundHrefButton, SecondarySolidSmallRoundIconButton, SecondarySolidSmallRoundIconHrefButton, SegmentControlComponent, SelectSearchComponent, ShareButtonsComponent, SimpleComponent, SkeletonComponent, SolidBlockButton, SolidDefault, SolidDefaultBlock, SolidDefaultButton, SolidDefaultFull, SolidDefaultRound, SolidDefaultRoundBlock, SolidDefaultRoundButton, SolidDefaultRoundFull, SolidFullButton, SolidLargeButton, SolidLargeRoundButton, SolidSmallButton, SolidSmallRoundButton, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, TabbedContentComponent, TabsComponent, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TicketGridComponent, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TypedCollection, VALTECH_AUTH_CONFIG, VALTECH_FIREBASE_CONFIG, WinnerDisplayComponent, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, buildPath, collections, createFirebaseConfig, createGlowCardProps, createNumberFromToField, createTitleProps, extractPathParams, getCollectionPath, getDocumentId, goToTop, guestGuard, hasEmulators, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isValidPath, joinPath, maxLength, permissionGuard, permissionGuardFromRoute, provideValtechAuth, provideValtechAuthInterceptor, provideValtechFirebase, query, replaceSpecialChars, resolveColor, resolveInputDefaultValue, roleGuard, storagePaths, superAdminGuard };
|
|
21140
25331
|
//# sourceMappingURL=valtech-components.mjs.map
|