From 3ae6ce5dff0da50fea456d9c926d360cc50f05d3 Mon Sep 17 00:00:00 2001 From: James Daniels Date: Fri, 8 Oct 2021 01:33:57 -0400 Subject: [PATCH] feat(auth-guard): Adding in modular auth guards (#3001) * Adding modular auth guards * Flushing out some basic tests * Refactored how `isSupported` is handled * Firestore wasn't passing the injector, need tests for this too * Fixed the version check on firebase-tools * Fixed firebase-tools project creation * Cleaned up some of the error messaging --- firebase.json | 6 +++ samples/advanced/src/app/app.module.ts | 2 +- src/analytics/analytics.module.ts | 34 ++++---------- src/analytics/analytics.spec.ts | 50 ++++++++++++++++++++ src/analytics/firebase.ts | 6 ++- src/analytics/overrides.ts | 3 ++ src/analytics/screen-tracking.service.ts | 39 +++++++--------- src/analytics/user-tracking.service.ts | 40 ++++++++-------- src/app-check/app-check.spec.ts | 43 +++++++++++++++++ src/app/app.spec.ts | 37 +++++++++++++++ src/auth-guard/auth-guard.module.ts | 13 ++++++ src/auth-guard/auth-guard.spec.ts | 57 +++++++++++++++++++++++ src/auth-guard/auth-guard.ts | 53 +++++++++++++++++++++ src/auth-guard/package.json | 12 +++++ src/auth-guard/public_api.ts | 2 + src/auth/auth.spec.ts | 43 +++++++++++++++++ src/core.ts | 56 +++++++++++++++++++++- src/database/database.spec.ts | 43 +++++++++++++++++ src/firestore/firestore.module.ts | 2 +- src/firestore/firestore.spec.ts | 43 +++++++++++++++++ src/firestore/lite/lite.module.ts | 5 +- src/firestore/lite/lite.spec.ts | 43 +++++++++++++++++ src/functions/functions.spec.ts | 43 +++++++++++++++++ src/messaging/firebase.ts | 6 ++- src/messaging/messaging.module.ts | 24 ++++------ src/messaging/messaging.spec.ts | 49 +++++++++++++++++++ src/messaging/overrides.ts | 3 ++ src/performance/performance.spec.ts | 39 ++++++++++++++++ src/remote-config/firebase.ts | 6 ++- src/remote-config/overrides.ts | 3 ++ src/remote-config/remote-config.module.ts | 21 +++------ src/remote-config/remote-config.spec.ts | 50 ++++++++++++++++++++ src/schematics/firebaseTools.ts | 5 +- src/schematics/setup/index.ts | 2 +- src/schematics/setup/prompts.ts | 2 +- src/storage/storage.spec.ts | 43 +++++++++++++++++ src/zones.ts | 6 ++- tools/build.ts | 19 ++++++-- 38 files changed, 836 insertions(+), 117 deletions(-) create mode 100644 src/analytics/analytics.spec.ts create mode 100644 src/analytics/overrides.ts create mode 100644 src/app-check/app-check.spec.ts create mode 100644 src/app/app.spec.ts create mode 100644 src/auth-guard/auth-guard.module.ts create mode 100644 src/auth-guard/auth-guard.spec.ts create mode 100644 src/auth-guard/auth-guard.ts create mode 100644 src/auth-guard/package.json create mode 100644 src/auth-guard/public_api.ts create mode 100644 src/auth/auth.spec.ts create mode 100644 src/database/database.spec.ts create mode 100644 src/firestore/firestore.spec.ts create mode 100644 src/firestore/lite/lite.spec.ts create mode 100644 src/functions/functions.spec.ts create mode 100644 src/messaging/messaging.spec.ts create mode 100644 src/messaging/overrides.ts create mode 100644 src/performance/performance.spec.ts create mode 100644 src/remote-config/overrides.ts create mode 100644 src/remote-config/remote-config.spec.ts create mode 100644 src/storage/storage.spec.ts diff --git a/firebase.json b/firebase.json index dd10ccc79..c4a1ccef5 100644 --- a/firebase.json +++ b/firebase.json @@ -10,6 +10,12 @@ "rules": "test/storage.rules" }, "emulators": { + "auth": { + "port": 9099 + }, + "functions": { + "port": 5001 + }, "firestore": { "port": 8080 }, diff --git a/samples/advanced/src/app/app.module.ts b/samples/advanced/src/app/app.module.ts index d7e29138f..8c43d1335 100644 --- a/samples/advanced/src/app/app.module.ts +++ b/samples/advanced/src/app/app.module.ts @@ -59,8 +59,8 @@ export const FIREBASE_ADMIN = new InjectionToken('firebase-admin'); }, [new Optional(), FIREBASE_ADMIN]), ], providers: [ - UserTrackingService, ScreenTrackingService, + UserTrackingService, ], bootstrap: [ ], }) diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts index 3dfabd3c1..6b81e8400 100644 --- a/src/analytics/analytics.module.ts +++ b/src/analytics/analytics.module.ts @@ -1,30 +1,23 @@ import { NgModule, Optional, NgZone, InjectionToken, ModuleWithProviders, APP_INITIALIZER, Injector } from '@angular/core'; -import { Analytics as FirebaseAnalytics, isSupported } from 'firebase/analytics'; -import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION } from '@angular/fire'; +import { Analytics as FirebaseAnalytics } from 'firebase/analytics'; +import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION, ɵisAnalyticsSupportedFactory } from '@angular/fire'; import { Analytics, ANALYTICS_PROVIDER_NAME, AnalyticsInstances } from './analytics'; import { FirebaseApps, FirebaseApp } from '@angular/fire/app'; import { registerVersion } from 'firebase/app'; import { ScreenTrackingService } from './screen-tracking.service'; import { UserTrackingService } from './user-tracking.service'; -export const PROVIDED_ANALYTICS_INSTANCE_FACTORIES = new InjectionToken Analytics>>('angularfire2.analytics-instances.factory'); export const PROVIDED_ANALYTICS_INSTANCES = new InjectionToken('angularfire2.analytics-instances'); -const IS_SUPPORTED = new InjectionToken('angularfire2.analytics.isSupported'); -const isSupportedValueSymbol = Symbol('angularfire2.analytics.isSupported.value'); -export const isSupportedPromiseSymbol = Symbol('angularfire2.analytics.isSupported'); - -globalThis[isSupportedPromiseSymbol] ||= isSupported().then(it => globalThis[isSupportedValueSymbol] = it); - -export function defaultAnalyticsInstanceFactory(isSupported: boolean, provided: FirebaseAnalytics[]|undefined, defaultApp: FirebaseApp) { - if (!isSupported) { return null; } +export function defaultAnalyticsInstanceFactory(provided: FirebaseAnalytics[]|undefined, defaultApp: FirebaseApp) { + if (!ɵisAnalyticsSupportedFactory.sync()) { return null; } const defaultAnalytics = ɵgetDefaultInstanceOf(ANALYTICS_PROVIDER_NAME, provided, defaultApp); return defaultAnalytics && new Analytics(defaultAnalytics); } export function analyticsInstanceFactory(fn: (injector: Injector) => FirebaseAnalytics) { - return (zone: NgZone, isSupported: boolean, injector: Injector) => { - if (!isSupported) { return null; } + return (zone: NgZone, injector: Injector) => { + if (!ɵisAnalyticsSupportedFactory.sync()) { return null; } const analytics = zone.runOutsideAngular(() => fn(injector)); return new Analytics(analytics); }; @@ -41,7 +34,6 @@ const DEFAULT_ANALYTICS_INSTANCE_PROVIDER = { provide: Analytics, useFactory: defaultAnalyticsInstanceFactory, deps: [ - IS_SUPPORTED, [new Optional(), PROVIDED_ANALYTICS_INSTANCES ], FirebaseApp, ] @@ -53,15 +45,15 @@ const DEFAULT_ANALYTICS_INSTANCE_PROVIDER = { ANALYTICS_INSTANCES_PROVIDER, { provide: APP_INITIALIZER, - useValue: () => globalThis[isSupportedPromiseSymbol], + useValue: ɵisAnalyticsSupportedFactory.async, multi: true, } ] }) export class AnalyticsModule { constructor( - @Optional() _screenTracking: ScreenTrackingService, - @Optional() _userTracking: UserTrackingService, + @Optional() _screenTrackingService: ScreenTrackingService, + @Optional() _userTrackingService: UserTrackingService, ) { registerVersion('angularfire', VERSION.full, 'analytics'); } @@ -71,19 +63,11 @@ export function provideAnalytics(fn: (injector: Injector) => FirebaseAnalytics, return { ngModule: AnalyticsModule, providers: [{ - provide: IS_SUPPORTED, - useFactory: () => globalThis[isSupportedValueSymbol], - }, { - provide: PROVIDED_ANALYTICS_INSTANCE_FACTORIES, - useValue: fn, - multi: true, - }, { provide: PROVIDED_ANALYTICS_INSTANCES, useFactory: analyticsInstanceFactory(fn), multi: true, deps: [ NgZone, - IS_SUPPORTED, Injector, ɵAngularFireSchedulers, FirebaseApps, diff --git a/src/analytics/analytics.spec.ts b/src/analytics/analytics.spec.ts new file mode 100644 index 000000000..c061a4b8f --- /dev/null +++ b/src/analytics/analytics.spec.ts @@ -0,0 +1,50 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Analytics, provideAnalytics, getAnalytics, isSupported } from '@angular/fire/analytics'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Analytics', () => { + let app: FirebaseApp; + let analytics: Analytics; + let providedAnalytics: Analytics; + let appName: string; + + beforeAll(done => { + // The APP_INITIALIZER that is making isSupported() sync for DI may not + // be done evaulating by the time we inject from the TestBed. We can + // ensure correct behavior by waiting for the (global) isSuppported() promise + // to resolve. + isSupported().then(() => done()); + }); + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideAnalytics(() => { + providedAnalytics = getAnalytics(getApp(appName)); + return providedAnalytics; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + analytics = TestBed.inject(Analytics); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedAnalytics).toBeTruthy(); + expect(analytics).toEqual(providedAnalytics); + expect(analytics.app).toEqual(app); + }); + + }); + +}); diff --git a/src/analytics/firebase.ts b/src/analytics/firebase.ts index d1c0abf60..0eea5bf5b 100644 --- a/src/analytics/firebase.ts +++ b/src/analytics/firebase.ts @@ -4,7 +4,6 @@ import { ɵzoneWrap } from '@angular/fire'; import { getAnalytics as _getAnalytics, initializeAnalytics as _initializeAnalytics, - isSupported as _isSupported, logEvent as _logEvent, setAnalyticsCollectionEnabled as _setAnalyticsCollectionEnabled, setCurrentScreen as _setCurrentScreen, @@ -13,9 +12,12 @@ import { setUserProperties as _setUserProperties } from 'firebase/analytics'; +export { + isSupported +} from './overrides'; + export const getAnalytics = ɵzoneWrap(_getAnalytics, true); export const initializeAnalytics = ɵzoneWrap(_initializeAnalytics, true); -export const isSupported = ɵzoneWrap(_isSupported, true); export const logEvent = ɵzoneWrap(_logEvent, true); export const setAnalyticsCollectionEnabled = ɵzoneWrap(_setAnalyticsCollectionEnabled, true); export const setCurrentScreen = ɵzoneWrap(_setCurrentScreen, true); diff --git a/src/analytics/overrides.ts b/src/analytics/overrides.ts new file mode 100644 index 000000000..ce44ea327 --- /dev/null +++ b/src/analytics/overrides.ts @@ -0,0 +1,3 @@ +import { ɵisAnalyticsSupportedFactory } from '@angular/fire'; + +export const isSupported = ɵisAnalyticsSupportedFactory.async; diff --git a/src/analytics/screen-tracking.service.ts b/src/analytics/screen-tracking.service.ts index 2f9cdfb74..06078a587 100644 --- a/src/analytics/screen-tracking.service.ts +++ b/src/analytics/screen-tracking.service.ts @@ -1,16 +1,14 @@ -import { Inject, ComponentFactoryResolver, Injectable, NgZone, OnDestroy, Optional, Injector } from '@angular/core'; +import { ComponentFactoryResolver, Injectable, NgZone, OnDestroy, Optional, Injector } from '@angular/core'; import { of, Subscription, Observable } from 'rxjs'; import { distinctUntilChanged, filter, groupBy, map, mergeMap, pairwise, startWith, switchMap } from 'rxjs/operators'; import { ActivationEnd, Router, ɵEmptyOutletComponent } from '@angular/router'; import { Title } from '@angular/platform-browser'; import { VERSION } from '@angular/fire'; -import { FirebaseApp } from '@angular/fire/app'; import { registerVersion } from 'firebase/app'; import { Analytics } from './analytics'; -import { logEvent } from './firebase'; +import { logEvent, isSupported } from './firebase'; import { UserTrackingService } from './user-tracking.service'; -import { analyticsInstanceFactory, defaultAnalyticsInstanceFactory, isSupportedPromiseSymbol, PROVIDED_ANALYTICS_INSTANCE_FACTORIES } from './analytics.module'; const FIREBASE_EVENT_ORIGIN_KEY = 'firebase_event_origin'; const FIREBASE_PREVIOUS_SCREEN_CLASS_KEY = 'firebase_previous_class'; @@ -153,28 +151,23 @@ export class ScreenTrackingService implements OnDestroy { componentFactoryResolver: ComponentFactoryResolver, zone: NgZone, @Optional() userTrackingService: UserTrackingService, - firebaseApp: FirebaseApp, - @Inject(PROVIDED_ANALYTICS_INSTANCE_FACTORIES) analyticsInstanceFactories: Array<(injector: Injector) => Analytics>, injector: Injector, ) { registerVersion('angularfire', VERSION.full, 'screen-tracking'); - if (!router) { return this; } - // Analytics is not ready to be injected yet, as the APP_INITIALIZER hasn't evulated yet, do this the hard way - const analyticsInstance: Promise = globalThis[isSupportedPromiseSymbol].then((isSupported: boolean) => { - const analyticsInstances = analyticsInstanceFactories.map(fn => analyticsInstanceFactory(fn)(zone, isSupported, injector)); - return defaultAnalyticsInstanceFactory(isSupported, analyticsInstances, firebaseApp); - }); - zone.runOutsideAngular(() => { - this.disposable = ɵscreenViewEvent(router, title, componentFactoryResolver).pipe( - switchMap(async params => { - if (userTrackingService) { - await userTrackingService.initialized; - } - const analytics = await analyticsInstance; - if (!analytics) { return; } - return logEvent(analytics, SCREEN_VIEW_EVENT, params); - }) - ).subscribe(); + // The APP_INITIALIZER that is making isSupported() sync for the sake of convenient DI + // may not be done when services are initialized. Guard the functionality by first ensuring + // that the (global) promise has resolved, then get Analytics from the injector. + isSupported().then(() => { + const analytics = injector.get(Analytics); + if (!router || !analytics) { return; } + zone.runOutsideAngular(() => { + this.disposable = ɵscreenViewEvent(router, title, componentFactoryResolver).pipe( + switchMap(async params => { + if (userTrackingService) { await userTrackingService.initialized; } + return logEvent(analytics, SCREEN_VIEW_EVENT, params); + }) + ).subscribe(); + }); }); } diff --git a/src/analytics/user-tracking.service.ts b/src/analytics/user-tracking.service.ts index 1c6387afe..e4b7025f8 100644 --- a/src/analytics/user-tracking.service.ts +++ b/src/analytics/user-tracking.service.ts @@ -1,41 +1,43 @@ -import { Inject, Injectable, Injector, NgZone, OnDestroy } from '@angular/core'; -import { Analytics } from './analytics'; +import { Injectable, Injector, NgZone, OnDestroy } from '@angular/core'; import { Subscription } from 'rxjs'; import { VERSION } from '@angular/fire'; import { Auth, authState } from '@angular/fire/auth'; import { registerVersion } from 'firebase/app'; -import { setUserId } from './firebase'; -import { analyticsInstanceFactory, defaultAnalyticsInstanceFactory, isSupportedPromiseSymbol, PROVIDED_ANALYTICS_INSTANCE_FACTORIES } from './analytics.module'; -import { FirebaseApp } from '@angular/fire/app'; + +import { Analytics } from './analytics'; +import { setUserId, isSupported } from './firebase'; @Injectable() export class UserTrackingService implements OnDestroy { public readonly initialized: Promise; - private readonly disposables: Array = []; + private disposables: Array = []; constructor( auth: Auth, zone: NgZone, - @Inject(PROVIDED_ANALYTICS_INSTANCE_FACTORIES) analyticsInstanceFactories: Array<(injector: Injector) => Analytics>, injector: Injector, - firebaseApp: FirebaseApp, ) { registerVersion('angularfire', VERSION.full, 'user-tracking'); - // Analytics is not ready to be injected yet, as the APP_INITIALIZER hasn't evulated yet, do this the hard way - const analyticsInstance: Promise = globalThis[isSupportedPromiseSymbol].then((isSupported: boolean) => { - const analyticsInstances = analyticsInstanceFactories.map(fn => analyticsInstanceFactory(fn)(zone, isSupported, injector)); - return defaultAnalyticsInstanceFactory(isSupported, analyticsInstances, firebaseApp); - }); let resolveInitialized: () => void; this.initialized = zone.runOutsideAngular(() => new Promise(resolve => { resolveInitialized = resolve; })); - this.disposables = [ - // TODO add credential tracking back in - authState(auth).subscribe(user => { - analyticsInstance.then(analytics => analytics && setUserId(analytics, user?.uid)); + // The APP_INITIALIZER that is making isSupported() sync for the sake of convenient DI + // may not be done when services are initialized. Guard the functionality by first ensuring + // that the (global) promise has resolved, then get Analytics from the injector. + isSupported().then(() => { + const analytics = injector.get(Analytics); + if (analytics) { + this.disposables = [ + // TODO add credential tracking back in + authState(auth).subscribe(user => { + setUserId(analytics, user?.uid); + resolveInitialized(); + }), + ]; + } else { resolveInitialized(); - }), - ]; + } + }); } ngOnDestroy() { diff --git a/src/app-check/app-check.spec.ts b/src/app-check/app-check.spec.ts new file mode 100644 index 000000000..42077c531 --- /dev/null +++ b/src/app-check/app-check.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Auth, provideAuth, getAuth, connectAuthEmulator } from '@angular/fire/auth'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Auth', () => { + let app: FirebaseApp; + let auth: Auth; + let providedAuth: Auth; + let appName: string; + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideAuth(() => { + providedAuth = getAuth(getApp(appName)); + connectAuthEmulator(providedAuth, 'http://localhost:9099'); + return providedAuth; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + auth = TestBed.inject(Auth); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(auth).toBeTruthy(); + expect(auth).toEqual(providedAuth); + expect(auth.app).toEqual(app); + }); + + }); + +}); diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts new file mode 100644 index 000000000..daf488742 --- /dev/null +++ b/src/app/app.spec.ts @@ -0,0 +1,37 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('FirebaseApp', () => { + let app: FirebaseApp; + let providedApp: FirebaseApp; + let appName: string; + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => { + providedApp = initializeApp(COMMON_CONFIG, appName); + return providedApp; + }) + ], + }); + app = TestBed.inject(FirebaseApp); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(app).toBeTruthy(); + expect(app).toEqual(providedApp); + }); + + }); + +}); diff --git a/src/auth-guard/auth-guard.module.ts b/src/auth-guard/auth-guard.module.ts new file mode 100644 index 000000000..76dd87889 --- /dev/null +++ b/src/auth-guard/auth-guard.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { AuthGuard } from './auth-guard'; +import { registerVersion } from 'firebase/app'; +import { VERSION } from '@angular/fire'; + +@NgModule({ + providers: [ AuthGuard ] +}) +export class AuthGuardModule { + constructor() { + registerVersion('angularfire', VERSION.full, 'auth-guard'); + } +} diff --git a/src/auth-guard/auth-guard.spec.ts b/src/auth-guard/auth-guard.spec.ts new file mode 100644 index 000000000..c33ee294d --- /dev/null +++ b/src/auth-guard/auth-guard.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Auth, provideAuth, getAuth, connectAuthEmulator } from '@angular/fire/auth'; +import { COMMON_CONFIG } from '../test-config'; +import { AuthGuard, AuthGuardModule } from '@angular/fire/auth-guard'; +import { Router, RouterModule } from '@angular/router'; +import { APP_BASE_HREF } from '@angular/common'; +import { rando } from '../utils'; + +class TestComponent { } + +describe('AuthGuard', () => { + let app: FirebaseApp; + let auth: Auth; + let authGuard: AuthGuard; + let router: Router; + let appName: string; + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideAuth(() => { + const auth = getAuth(getApp(appName)); + connectAuthEmulator(auth, 'http://localhost:9099'); + return auth; + }), + AuthGuardModule, + RouterModule.forRoot([ + { path: 'a', component: TestComponent, canActivate: [AuthGuard] } + ]) + ], + providers: [ + { provide: APP_BASE_HREF, useValue: 'http://localhost:4200/' } + ] + }); + + app = TestBed.inject(FirebaseApp); + auth = TestBed.inject(Auth); + authGuard = TestBed.inject(AuthGuard); + router = TestBed.inject(Router); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(AuthGuard).toBeTruthy(); + }); + + it('router should be valid', () => { + expect(router).toBeTruthy(); + }); + +}); diff --git a/src/auth-guard/auth-guard.ts b/src/auth-guard/auth-guard.ts new file mode 100644 index 000000000..27d61ee48 --- /dev/null +++ b/src/auth-guard/auth-guard.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable, of, pipe, UnaryFunction } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { Auth, user } from '@angular/fire/auth'; +import { User } from 'firebase/auth'; + +export type AuthPipeGenerator = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => AuthPipe; +export type AuthPipe = UnaryFunction, Observable>; + +export const loggedIn: AuthPipe = map(user => !!user); + +@Injectable({ + providedIn: 'any' +}) +export class AuthGuard implements CanActivate { + + constructor(private router: Router, private auth: Auth) {} + + canActivate = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const authPipeFactory = next.data.authGuardPipe as AuthPipeGenerator || (() => loggedIn); + return user(this.auth).pipe( + take(1), + authPipeFactory(next, state), + map(can => { + if (typeof can === 'boolean') { + return can; + } else if (Array.isArray(can)) { + return this.router.createUrlTree(can); + } else { + // TODO(EdricChan03): Add tests + return this.router.parseUrl(can); + } + }) + ); + } + +} + +export const canActivate = (pipe: AuthPipeGenerator) => ({ + canActivate: [ AuthGuard ], data: { authGuardPipe: pipe } +}); + +export const isNotAnonymous: AuthPipe = map(user => !!user && !user.isAnonymous); +export const idTokenResult = switchMap((user: User|null) => user ? user.getIdTokenResult() : of(null)); +export const emailVerified: AuthPipe = map(user => !!user && user.emailVerified); +export const customClaims = pipe(idTokenResult, map(idTokenResult => idTokenResult ? idTokenResult.claims : [])); +export const hasCustomClaim: (claim: string) => AuthPipe = + (claim) => pipe(customClaims, map(claims => claims.hasOwnProperty(claim))); +export const redirectUnauthorizedTo: (redirect: string|any[]) => AuthPipe = + (redirect) => pipe(loggedIn, map(loggedIn => loggedIn || redirect)); +export const redirectLoggedInTo: (redirect: string|any[]) => AuthPipe = + (redirect) => pipe(loggedIn, map(loggedIn => loggedIn && redirect || true)); diff --git a/src/auth-guard/package.json b/src/auth-guard/package.json new file mode 100644 index 000000000..1507a99e1 --- /dev/null +++ b/src/auth-guard/package.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../node_modules/ng-packagr/package.schema.json", + "ngPackage": { + "lib": { + "entryFile": "public_api.ts", + "umdModuleIds": { + "firebase/app": "firebase", + "firebase/auth": "firebase-auth" + } + } + } +} diff --git a/src/auth-guard/public_api.ts b/src/auth-guard/public_api.ts new file mode 100644 index 000000000..468fd9657 --- /dev/null +++ b/src/auth-guard/public_api.ts @@ -0,0 +1,2 @@ +export * from './auth-guard'; +export * from './auth-guard.module'; diff --git a/src/auth/auth.spec.ts b/src/auth/auth.spec.ts new file mode 100644 index 000000000..ac536e779 --- /dev/null +++ b/src/auth/auth.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Auth, provideAuth, getAuth, connectAuthEmulator } from '@angular/fire/auth'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Auth', () => { + let app: FirebaseApp; + let auth: Auth; + let providedAuth: Auth; + let appName: string; + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideAuth(() => { + providedAuth = getAuth(getApp(appName)); + connectAuthEmulator(providedAuth, 'http://localhost:9099'); + return providedAuth; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + auth = TestBed.inject(Auth); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedAuth).toBeTruthy(); + expect(auth).toEqual(providedAuth); + expect(auth.app).toEqual(app); + }); + + }); + +}); diff --git a/src/core.ts b/src/core.ts index 6fb7db8fd..38e8b7c3f 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,9 +1,63 @@ -import { isDevMode, NgZone, Version } from '@angular/core'; +import { Version } from '@angular/core'; import { FirebaseApp, getApps } from 'firebase/app'; import { ComponentContainer } from '@firebase/component'; +import { isSupported as isRemoteConfigSupported } from 'firebase/remote-config'; +import { isSupported as isMessagingSupported } from 'firebase/messaging'; +import { isSupported as isAnalyticsSupported } from 'firebase/analytics'; export const VERSION = new Version('ANGULARFIRE2_VERSION'); +const isAnalyticsSupportedValueSymbol = '__angularfire_symbol__analyticsIsSupportedValue'; +const isAnalyticsSupportedPromiseSymbol = '__angularfire_symbol__analyticsIsSupported'; +const isRemoteConfigSupportedValueSymbol = '__angularfire_symbol__remoteConfigIsSupportedValue'; +const isRemoteConfigSupportedPromiseSymbol = '__angularfire_symbol__remoteConfigIsSupported'; +const isMessagingSupportedValueSymbol = '__angularfire_symbol__messagingIsSupportedValue'; +const isMessagingSupportedPromiseSymbol = '__angularfire_symbol__messagingIsSupported'; + +globalThis[isAnalyticsSupportedPromiseSymbol] ||= isAnalyticsSupported().then(it => + globalThis[isAnalyticsSupportedValueSymbol] = it +); + +globalThis[isMessagingSupportedPromiseSymbol] ||= isMessagingSupported().then(it => + globalThis[isMessagingSupportedValueSymbol] = it +); + +globalThis[isRemoteConfigSupportedPromiseSymbol] ||= isRemoteConfigSupported().then(it => + globalThis[isRemoteConfigSupportedValueSymbol] = it +); + +const isSupportedError = (module: string) => + `The APP_INITIALIZER that is "making" isSupported() sync for the sake of convenient DI has not resolved in this +context. Rather than injecting ${module} in the constructor, first ensure that ${module} is supported by calling +\`await isSupported()\`, then retrieve the instance from the injector manually \`injector.get(${module})\`.`; + +export const ɵisMessagingSupportedFactory = { + async: () => globalThis[isMessagingSupportedPromiseSymbol], + sync: () => { + const ret = globalThis[isMessagingSupportedValueSymbol]; + if (ret === undefined) { throw new Error(isSupportedError('Messaging')); } + return ret; + } +}; + +export const ɵisRemoteConfigSupportedFactory = { + async: () => globalThis[isRemoteConfigSupportedPromiseSymbol], + sync: () => { + const ret = globalThis[isRemoteConfigSupportedValueSymbol]; + if (ret === undefined) { throw new Error(isSupportedError('RemoteConfig')); } + return ret; + } +}; + +export const ɵisAnalyticsSupportedFactory = { + async: () => globalThis[isAnalyticsSupportedPromiseSymbol], + sync: () => { + const ret = globalThis[isAnalyticsSupportedValueSymbol]; + if (ret === undefined) { throw new Error(isSupportedError('Analytics')); } + return ret; + } +}; + // TODO is there a better way to get at the internal types? interface FirebaseAppWithContainer extends FirebaseApp { container: ComponentContainer; diff --git a/src/database/database.spec.ts b/src/database/database.spec.ts new file mode 100644 index 000000000..682176da8 --- /dev/null +++ b/src/database/database.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Database, provideDatabase, getDatabase, connectDatabaseEmulator } from '@angular/fire/database'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Database', () => { + let app: FirebaseApp; + let database: Database; + let providedDatabase: Database; + let appName: string; + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideDatabase(() => { + providedDatabase = getDatabase(getApp(appName)); + connectDatabaseEmulator(providedDatabase, 'localhost', 9000); + return providedDatabase; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + database = TestBed.inject(Database); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedDatabase).toBeTruthy(); + expect(database).toEqual(providedDatabase); + expect(database.app).toEqual(app); + }); + + }); + +}); diff --git a/src/firestore/firestore.module.ts b/src/firestore/firestore.module.ts index 9b5e6858e..49d4cda25 100644 --- a/src/firestore/firestore.module.ts +++ b/src/firestore/firestore.module.ts @@ -49,7 +49,7 @@ export class FirestoreModule { } } -export function provideFirestore(fn: () => FirebaseFirestore, ...deps: any[]): ModuleWithProviders { +export function provideFirestore(fn: (injector: Injector) => FirebaseFirestore, ...deps: any[]): ModuleWithProviders { return { ngModule: FirestoreModule, providers: [{ diff --git a/src/firestore/firestore.spec.ts b/src/firestore/firestore.spec.ts new file mode 100644 index 000000000..c08814ceb --- /dev/null +++ b/src/firestore/firestore.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Firestore, provideFirestore, getFirestore, connectFirestoreEmulator } from '@angular/fire/firestore'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Firestore', () => { + let app: FirebaseApp; + let firestore: Firestore; + let providedFirestore: Firestore; + let appName: string; + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideFirestore(() => { + providedFirestore = getFirestore(getApp(appName)); + connectFirestoreEmulator(providedFirestore, 'localhost', 8080); + return providedFirestore; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + firestore = TestBed.inject(Firestore); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedFirestore).toBeTruthy(); + expect(firestore).toEqual(providedFirestore); + expect(firestore.app).toEqual(app); + }); + + }); + +}); diff --git a/src/firestore/lite/lite.module.ts b/src/firestore/lite/lite.module.ts index 13503a675..8d633b1ae 100644 --- a/src/firestore/lite/lite.module.ts +++ b/src/firestore/lite/lite.module.ts @@ -5,6 +5,7 @@ import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION } from '@angul import { Firestore, FirestoreInstances, FIRESTORE_PROVIDER_NAME } from './lite'; import { FirebaseApps, FirebaseApp } from '@angular/fire/app'; import { registerVersion } from 'firebase/app'; +import { AppCheckInstances } from '@angular/fire/app-check'; export const PROVIDED_FIRESTORE_INSTANCES = new InjectionToken('angularfire2.firestore-lite-instances'); @@ -48,7 +49,7 @@ export class FirestoreModule { } } -export function provideFirestore(fn: () => FirebaseFirestore): ModuleWithProviders { +export function provideFirestore(fn: (injector: Injector) => FirebaseFirestore, ...deps: any[]): ModuleWithProviders { return { ngModule: FirestoreModule, providers: [{ @@ -62,6 +63,8 @@ export function provideFirestore(fn: () => FirebaseFirestore): ModuleWithProvide FirebaseApps, // Firestore+Auth work better if Auth is loaded first [new Optional(), AuthInstances ], + [new Optional(), AppCheckInstances ], + ...deps, ] }] }; diff --git a/src/firestore/lite/lite.spec.ts b/src/firestore/lite/lite.spec.ts new file mode 100644 index 000000000..64230276d --- /dev/null +++ b/src/firestore/lite/lite.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Firestore, provideFirestore, getFirestore, connectFirestoreEmulator } from '@angular/fire/firestore/lite'; +import { COMMON_CONFIG } from '../../test-config'; +import { rando } from '../../utils'; + +describe('Firestore-lite', () => { + let app: FirebaseApp; + let firestore: Firestore; + let providedFirestore: Firestore; + let appName: string; + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideFirestore(() => { + providedFirestore = getFirestore(getApp(appName)); + connectFirestoreEmulator(providedFirestore, 'localhost', 8080); + return providedFirestore; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + firestore = TestBed.inject(Firestore); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedFirestore).toBeTruthy(); + expect(firestore).toEqual(providedFirestore); + expect(firestore.app).toEqual(app); + }); + + }); + +}); diff --git a/src/functions/functions.spec.ts b/src/functions/functions.spec.ts new file mode 100644 index 000000000..671110cc3 --- /dev/null +++ b/src/functions/functions.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Functions, provideFunctions, getFunctions, connectFunctionsEmulator } from '@angular/fire/functions'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Functions', () => { + let app: FirebaseApp; + let functions: Functions; + let providedFunctions: Functions; + let appName: string; + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideFunctions(() => { + providedFunctions = getFunctions(getApp(appName)); + connectFunctionsEmulator(providedFunctions, 'localhost', 9099); + return providedFunctions; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + functions = TestBed.inject(Functions); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedFunctions).toBeTruthy(); + expect(functions).toEqual(providedFunctions); + expect(functions.app).toEqual(app); + }); + + }); + +}); diff --git a/src/messaging/firebase.ts b/src/messaging/firebase.ts index 21ea3ffc8..a26f1fc00 100644 --- a/src/messaging/firebase.ts +++ b/src/messaging/firebase.ts @@ -5,12 +5,14 @@ import { deleteToken as _deleteToken, getMessaging as _getMessaging, getToken as _getToken, - isSupported as _isSupported, onMessage as _onMessage } from 'firebase/messaging'; +export { + isSupported +} from './overrides'; + export const deleteToken = ɵzoneWrap(_deleteToken, true); export const getMessaging = ɵzoneWrap(_getMessaging, true); export const getToken = ɵzoneWrap(_getToken, true); -export const isSupported = ɵzoneWrap(_isSupported, true); export const onMessage = ɵzoneWrap(_onMessage, false); diff --git a/src/messaging/messaging.module.ts b/src/messaging/messaging.module.ts index 2d2b60552..21ff995fd 100644 --- a/src/messaging/messaging.module.ts +++ b/src/messaging/messaging.module.ts @@ -1,24 +1,21 @@ -import { NgModule, Optional, NgZone, InjectionToken, ModuleWithProviders, APP_INITIALIZER, Injector } from '@angular/core'; -import { isSupported, Messaging as FirebaseMessaging } from 'firebase/messaging'; -import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION } from '@angular/fire'; +import { NgModule, Optional, NgZone, InjectionToken, ModuleWithProviders, Injector, APP_INITIALIZER } from '@angular/core'; +import { Messaging as FirebaseMessaging } from 'firebase/messaging'; +import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION, ɵisMessagingSupportedFactory } from '@angular/fire'; import { Messaging, MessagingInstances, MESSAGING_PROVIDER_NAME } from './messaging'; import { FirebaseApps, FirebaseApp } from '@angular/fire/app'; import { registerVersion } from 'firebase/app'; const PROVIDED_MESSAGING_INSTANCES = new InjectionToken('angularfire2.messaging-instances'); -const IS_SUPPORTED = new InjectionToken('angularfire2.messaging.isSupported'); -const isSupportedSymbol = Symbol('angularfire2.messaging.isSupported'); - -export function defaultMessagingInstanceFactory(isSupported: boolean, provided: FirebaseMessaging[]|undefined, defaultApp: FirebaseApp) { - if (!isSupported) { return null; } +export function defaultMessagingInstanceFactory(provided: FirebaseMessaging[]|undefined, defaultApp: FirebaseApp) { + if (!ɵisMessagingSupportedFactory.sync()) { return null; } const defaultMessaging = ɵgetDefaultInstanceOf(MESSAGING_PROVIDER_NAME, provided, defaultApp); return defaultMessaging && new Messaging(defaultMessaging); } export function messagingInstanceFactory(fn: (injector: Injector) => FirebaseMessaging) { - return (zone: NgZone, isSupported: boolean, injector: Injector) => { - if (!isSupported) { return null; } + return (zone: NgZone, injector: Injector) => { + if (!ɵisMessagingSupportedFactory.sync()) { return null; } const messaging = zone.runOutsideAngular(() => fn(injector)); return new Messaging(messaging); }; @@ -35,7 +32,6 @@ const DEFAULT_MESSAGING_INSTANCE_PROVIDER = { provide: Messaging, useFactory: defaultMessagingInstanceFactory, deps: [ - IS_SUPPORTED, [new Optional(), PROVIDED_MESSAGING_INSTANCES ], FirebaseApp, ] @@ -47,7 +43,7 @@ const DEFAULT_MESSAGING_INSTANCE_PROVIDER = { MESSAGING_INSTANCES_PROVIDER, { provide: APP_INITIALIZER, - useValue: () => isSupported().then(it => globalThis[isSupportedSymbol] = it), + useValue: ɵisMessagingSupportedFactory.async, multi: true, }, ] @@ -62,15 +58,11 @@ export function provideMessaging(fn: () => FirebaseMessaging, ...deps: any[]): M return { ngModule: MessagingModule, providers: [{ - provide: IS_SUPPORTED, - useFactory: () => globalThis[isSupportedSymbol], - }, { provide: PROVIDED_MESSAGING_INSTANCES, useFactory: messagingInstanceFactory(fn), multi: true, deps: [ NgZone, - IS_SUPPORTED, Injector, ɵAngularFireSchedulers, FirebaseApps, diff --git a/src/messaging/messaging.spec.ts b/src/messaging/messaging.spec.ts new file mode 100644 index 000000000..6314b98e7 --- /dev/null +++ b/src/messaging/messaging.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Messaging, provideMessaging, getMessaging, isSupported } from '@angular/fire/messaging'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Messaging', () => { + let app: FirebaseApp; + let messaging: Messaging; + let providedMessaging: Messaging; + let appName: string; + + beforeAll(done => { + // The APP_INITIALIZER that is making isSupported() sync for DI may not + // be done evaulating by the time we inject from the TestBed. We can + // ensure correct behavior by waiting for the (global) isSuppported() promise + // to resolve. + isSupported().then(() => done()); + }); + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideMessaging(() => { + providedMessaging = getMessaging(getApp(appName)); + return providedMessaging; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + messaging = TestBed.inject(Messaging); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedMessaging).toBeTruthy(); + expect(messaging).toEqual(providedMessaging); + }); + + }); + +}); diff --git a/src/messaging/overrides.ts b/src/messaging/overrides.ts new file mode 100644 index 000000000..88c61df14 --- /dev/null +++ b/src/messaging/overrides.ts @@ -0,0 +1,3 @@ +import { ɵisMessagingSupportedFactory } from '@angular/fire'; + +export const isSupported = ɵisMessagingSupportedFactory.async; diff --git a/src/performance/performance.spec.ts b/src/performance/performance.spec.ts new file mode 100644 index 000000000..385c2ca50 --- /dev/null +++ b/src/performance/performance.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Performance, providePerformance, getPerformance } from '@angular/fire/performance'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Performance', () => { + let app: FirebaseApp; + let performance: Performance; + let providedPerformance: Performance; + + describe('single injection', () => { + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG)), + providePerformance(() => { + providedPerformance = getPerformance(); + return providedPerformance; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + performance = TestBed.inject(Performance); + }); + + afterEach(() => { + }); + + it('should be injectable', () => { + expect(providedPerformance).toBeTruthy(); + expect(performance).toEqual(providedPerformance); + expect(performance.app).toEqual(app); + }); + + }); + +}); diff --git a/src/remote-config/firebase.ts b/src/remote-config/firebase.ts index dd0ba60d6..c44cb9f41 100644 --- a/src/remote-config/firebase.ts +++ b/src/remote-config/firebase.ts @@ -12,10 +12,13 @@ import { getRemoteConfig as _getRemoteConfig, getString as _getString, getValue as _getValue, - isSupported as _isSupported, setLogLevel as _setLogLevel } from 'firebase/remote-config'; +export { + isSupported +} from './overrides'; + export const activate = ɵzoneWrap(_activate, true); export const ensureInitialized = ɵzoneWrap(_ensureInitialized, true); export const fetchAndActivate = ɵzoneWrap(_fetchAndActivate, true); @@ -26,5 +29,4 @@ export const getNumber = ɵzoneWrap(_getNumber, true); export const getRemoteConfig = ɵzoneWrap(_getRemoteConfig, true); export const getString = ɵzoneWrap(_getString, true); export const getValue = ɵzoneWrap(_getValue, true); -export const isSupported = ɵzoneWrap(_isSupported, true); export const setLogLevel = ɵzoneWrap(_setLogLevel, true); diff --git a/src/remote-config/overrides.ts b/src/remote-config/overrides.ts new file mode 100644 index 000000000..6478d5a11 --- /dev/null +++ b/src/remote-config/overrides.ts @@ -0,0 +1,3 @@ +import { ɵisRemoteConfigSupportedFactory } from '@angular/fire'; + +export const isSupported = ɵisRemoteConfigSupportedFactory.async; diff --git a/src/remote-config/remote-config.module.ts b/src/remote-config/remote-config.module.ts index f9faeb2c9..2d503e110 100644 --- a/src/remote-config/remote-config.module.ts +++ b/src/remote-config/remote-config.module.ts @@ -1,28 +1,24 @@ import { NgModule, Optional, NgZone, InjectionToken, ModuleWithProviders, Injector, APP_INITIALIZER } from '@angular/core'; -import { RemoteConfig as FirebaseRemoteConfig, isSupported } from 'firebase/remote-config'; -import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION } from '@angular/fire'; +import { RemoteConfig as FirebaseRemoteConfig } from 'firebase/remote-config'; +import { ɵgetDefaultInstanceOf, ɵAngularFireSchedulers, VERSION, ɵisRemoteConfigSupportedFactory } from '@angular/fire'; import { RemoteConfig, RemoteConfigInstances, REMOTE_CONFIG_PROVIDER_NAME } from './remote-config'; import { FirebaseApps, FirebaseApp } from '@angular/fire/app'; import { registerVersion } from 'firebase/app'; export const PROVIDED_REMOTE_CONFIG_INSTANCES = new InjectionToken('angularfire2.remote-config-instances'); -const IS_SUPPORTED = new InjectionToken('angularfire2.remote-config.isSupported'); - -const isSupportedSymbol = Symbol('angularfire2.remote-config.isSupported'); export function defaultRemoteConfigInstanceFactory( - isSupported: boolean, provided: FirebaseRemoteConfig[]|undefined, defaultApp: FirebaseApp, ) { - if (!isSupported) { return null; } + if (!ɵisRemoteConfigSupportedFactory.sync()) { return null; } const defaultRemoteConfig = ɵgetDefaultInstanceOf(REMOTE_CONFIG_PROVIDER_NAME, provided, defaultApp); return defaultRemoteConfig && new RemoteConfig(defaultRemoteConfig); } export function remoteConfigInstanceFactory(fn: (injector: Injector) => FirebaseRemoteConfig) { - return (zone: NgZone, isSupported: boolean, injector: Injector) => { - if (!isSupported) { return null; } + return (zone: NgZone, injector: Injector) => { + if (!ɵisRemoteConfigSupportedFactory.sync()) { return null; } const remoteConfig = zone.runOutsideAngular(() => fn(injector)); return new RemoteConfig(remoteConfig); }; @@ -39,7 +35,6 @@ const DEFAULT_REMOTE_CONFIG_INSTANCE_PROVIDER = { provide: RemoteConfig, useFactory: defaultRemoteConfigInstanceFactory, deps: [ - IS_SUPPORTED, [new Optional(), PROVIDED_REMOTE_CONFIG_INSTANCES ], FirebaseApp, ] @@ -51,7 +46,7 @@ const DEFAULT_REMOTE_CONFIG_INSTANCE_PROVIDER = { REMOTE_CONFIG_INSTANCES_PROVIDER, { provide: APP_INITIALIZER, - useValue: () => isSupported().then(it => globalThis[isSupportedSymbol] = it), + useValue: ɵisRemoteConfigSupportedFactory.async, multi: true, }, ] @@ -66,15 +61,11 @@ export function provideRemoteConfig(fn: () => FirebaseRemoteConfig, ...deps: any return { ngModule: RemoteConfigModule, providers: [{ - provide: IS_SUPPORTED, - useFactory: () => globalThis[isSupportedSymbol], - }, { provide: PROVIDED_REMOTE_CONFIG_INSTANCES, useFactory: remoteConfigInstanceFactory(fn), multi: true, deps: [ NgZone, - IS_SUPPORTED, Injector, ɵAngularFireSchedulers, FirebaseApps, diff --git a/src/remote-config/remote-config.spec.ts b/src/remote-config/remote-config.spec.ts new file mode 100644 index 000000000..bd77f2560 --- /dev/null +++ b/src/remote-config/remote-config.spec.ts @@ -0,0 +1,50 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { RemoteConfig, provideRemoteConfig, getRemoteConfig, isSupported } from '@angular/fire/remote-config'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('RemoteConfig', () => { + let app: FirebaseApp; + let remoteConfig: RemoteConfig; + let providedRemoteConfig: RemoteConfig; + let appName: string; + + beforeAll(done => { + // The APP_INITIALIZER that is making isSupported() sync for DI may not + // be done evaulating by the time we inject from the TestBed. We can + // ensure correct behavior by waiting for the (global) isSuppported() promise + // to resolve. + isSupported().then(() => done()); + }); + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideRemoteConfig(() => { + providedRemoteConfig = getRemoteConfig(getApp(appName)); + return providedRemoteConfig; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + remoteConfig = TestBed.inject(RemoteConfig); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedRemoteConfig).toBeTruthy(); + expect(remoteConfig).toEqual(providedRemoteConfig); + expect(remoteConfig.app).toEqual(app); + }); + + }); + +}); diff --git a/src/schematics/firebaseTools.ts b/src/schematics/firebaseTools.ts index bc89da809..afd6488a3 100644 --- a/src/schematics/firebaseTools.ts +++ b/src/schematics/firebaseTools.ts @@ -1,6 +1,7 @@ import { FirebaseTools } from './interfaces'; import { spawn, execSync } from 'child_process'; import ora from 'ora'; +import * as semver from 'semver'; declare global { var firebaseTools: FirebaseTools|undefined; @@ -41,8 +42,8 @@ export const getFirebaseTools = () => globalThis.firebaseTools ? globalThis.firebaseTools = firebaseTools; const version = firebaseTools.cli.version(); console.log(`Using firebase-tools version ${version}`); - if (parseFloat(version) < 9.9) { - console.error('firebase-tools version 9.9+ is required, please upgrade'); + if (semver.compare(version, '9.9.0') === -1) { + console.error('firebase-tools version 9.9+ is required, please upgrade and run again'); return Promise.reject(); } return firebaseTools; diff --git a/src/schematics/setup/index.ts b/src/schematics/setup/index.ts index c9061d0cb..43b95fd9f 100644 --- a/src/schematics/setup/index.ts +++ b/src/schematics/setup/index.ts @@ -131,7 +131,7 @@ export const ngAddSetupProject = ( const [ defaultProjectName ] = getFirebaseProjectNameFromHost(host, ngProjectName); - const firebaseProject = await projectPrompt(defaultProjectName, { projectRoot }); + const firebaseProject = await projectPrompt(defaultProjectName, { projectRoot, account: user.email }); let hosting = { projectType: PROJECT_TYPE.Static, prerender: false }; let firebaseHostingSite: FirebaseHostingSite|undefined; diff --git a/src/schematics/setup/prompts.ts b/src/schematics/setup/prompts.ts index ee7c34046..6da0d6de3 100644 --- a/src/schematics/setup/prompts.ts +++ b/src/schematics/setup/prompts.ts @@ -166,7 +166,7 @@ export const projectPrompt = async (defaultProject: string|undefined, options: { message: 'What would you like to call your project?', default: projectId, }); - return await firebaseTools.projects.create(projectId, { ...options, displayName, nonInteractive: true }); + return await firebaseTools.projects.create(projectId, { account: (options as any).account, displayName, nonInteractive: true }); } // tslint:disable-next-line:no-non-null-assertion return (await projects).find(it => it.projectId === projectId)!; diff --git a/src/storage/storage.spec.ts b/src/storage/storage.spec.ts new file mode 100644 index 000000000..4aa2bc15e --- /dev/null +++ b/src/storage/storage.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { FirebaseApp, provideFirebaseApp, getApp, initializeApp, deleteApp } from '@angular/fire/app'; +import { Storage, provideStorage, getStorage, connectStorageEmulator } from '@angular/fire/storage'; +import { COMMON_CONFIG } from '../test-config'; +import { rando } from '../utils'; + +describe('Storage', () => { + let app: FirebaseApp; + let storage: Storage; + let providedStorage: Storage; + let appName: string; + + describe('single injection', () => { + + beforeEach(() => { + appName = rando(); + TestBed.configureTestingModule({ + imports: [ + provideFirebaseApp(() => initializeApp(COMMON_CONFIG, appName)), + provideStorage(() => { + providedStorage = getStorage(getApp(appName)); + connectStorageEmulator(providedStorage, 'localhost', 9199); + return providedStorage; + }), + ], + }); + app = TestBed.inject(FirebaseApp); + storage = TestBed.inject(Storage); + }); + + afterEach(() => { + deleteApp(app).catch(() => undefined); + }); + + it('should be injectable', () => { + expect(providedStorage).toBeTruthy(); + expect(storage).toEqual(providedStorage); + expect(storage.app).toEqual(app); + }); + + }); + +}); diff --git a/src/zones.ts b/src/zones.ts index 2b6421947..fc4eeeb25 100644 --- a/src/zones.ts +++ b/src/zones.ts @@ -88,7 +88,11 @@ export class ɵAngularFireSchedulers { function getSchedulers() { const schedulers = globalThis.ɵAngularFireScheduler as ɵAngularFireSchedulers|undefined; - if (!schedulers) { throw new Error('AngularFireModule has not been provided'); } + if (!schedulers) { + throw new Error( +`Either AngularFireModule has not been provided in your AppModule (this can be done manually or implictly using +provideFirebaseApp) or you're calling an AngularFire method outside of an NgModule (which is not supported).`); + } return schedulers; } diff --git a/tools/build.ts b/tools/build.ts index 77f39f2a3..206f4ff97 100644 --- a/tools/build.ts +++ b/tools/build.ts @@ -9,7 +9,7 @@ import * as glob from 'glob'; // TODO infer these from the package.json const MODULES = [ - 'core', 'app', 'app-check', 'compat', 'analytics', 'auth', 'database', 'firestore', 'functions', + 'core', 'app', 'app-check', 'auth-guard', 'compat', 'analytics', 'auth', 'database', 'firestore', 'functions', 'remote-config', 'storage', 'messaging', 'performance', 'compat/analytics', 'compat/auth-guard', 'compat/auth', 'compat/database', 'compat/firestore', 'compat/functions', 'compat/remote-config', 'compat/storage', 'compat/messaging', @@ -23,6 +23,7 @@ interface OverrideOptions { exportName?: string; zoneWrap?: boolean; blockUntilFirst?: boolean; + override?: boolean; } function zoneWrapExports() { @@ -35,7 +36,7 @@ function zoneWrapExports() { ) => { const imported = await import(path); const toBeExported: Array<[string, string, boolean]> = exports. - filter(it => !it.startsWith('_') && overrides[it] !== null). + filter(it => !it.startsWith('_') && overrides[it] !== null && overrides[it]?.override !== true). map(importName => { const zoneWrap = typeof imported[importName] === 'function' && (overrides[importName]?.zoneWrap ?? importName[0] !== importName[0].toUpperCase()); @@ -44,6 +45,7 @@ function zoneWrapExports() { }); const zoneWrapped = toBeExported.filter(([, , zoneWrap]) => zoneWrap); const rawExport = toBeExported.filter(([, , zoneWrap]) => !zoneWrap); + const overridden = Object.keys(overrides).filter(key => overrides[key]?.override); await writeFile(join(process.cwd(), 'src', `${module}/${name}.ts`), `// DO NOT MODIFY, this file is autogenerated by tools/build.ts ${path.startsWith('firebase/') ? `export * from '${path}';\n` : ''}${ zoneWrapped.length > 0 ? `import { ɵzoneWrap } from '@angular/fire'; @@ -54,11 +56,17 @@ import { export { ${rawExport.map(([importName, exportName]) => `${importName}${exportName === importName ? '' : `as ${exportName}`}`).join(',\n ')} } from '${path}'; +` : ''}${overridden.length > 0 ? ` +export { + ${overridden.join(',\n ')} +} from './overrides'; ` : ''} ${zoneWrapped.map(([importName, exportName]) => `export const ${exportName} = ɵzoneWrap(_${importName}, ${overrides[importName]?.blockUntilFirst ?? true});`).join('\n')} `); }; return Promise.all([ - reexport('analytics', 'firebase', 'firebase/analytics', tsKeys()), + reexport('analytics', 'firebase', 'firebase/analytics', tsKeys(), { + isSupported: { override: true }, + }), reexport('app', 'firebase', 'firebase/app', tsKeys()), reexport('app-check', 'firebase', 'firebase/app-check', tsKeys()), reexport('auth', 'rxfire', 'rxfire/auth', tsKeys()), @@ -83,6 +91,7 @@ ${zoneWrapped.map(([importName, exportName]) => `export const ${exportName} = ɵ reexport('functions', 'firebase', 'firebase/functions', tsKeys()), reexport('messaging', 'firebase', 'firebase/messaging', tsKeys(), { onMessage: { blockUntilFirst: false }, + isSupported: { override: true }, }), reexport('remote-config', 'rxfire', 'rxfire/remote-config', tsKeys(), { getValue: { exportName: 'getValueChanges' }, @@ -91,7 +100,9 @@ ${zoneWrapped.map(([importName, exportName]) => `export const ${exportName} = ɵ getBoolean: { exportName: 'getBooleanChanges' }, getAll: { exportName: 'getAllChanges' }, }), - reexport('remote-config', 'firebase', 'firebase/remote-config', tsKeys()), + reexport('remote-config', 'firebase', 'firebase/remote-config', tsKeys(), { + isSupported: { override: true }, + }), reexport('storage', 'rxfire', 'rxfire/storage', tsKeys(), { getDownloadURL: null, getMetadata: null,