diff --git a/packages/dbx-core/src/lib/auth/router/provider/uirouter/hook.ts b/packages/dbx-core/src/lib/auth/router/provider/uirouter/hook.ts index d648125a3..d7aa9b1e3 100644 --- a/packages/dbx-core/src/lib/auth/router/provider/uirouter/hook.ts +++ b/packages/dbx-core/src/lib/auth/router/provider/uirouter/hook.ts @@ -4,6 +4,7 @@ import { asSegueRef, asSegueRefString, SegueRefOrSegueRefRouterLink } from './.. import { DbxAuthService } from '../../../service/auth.service'; import { FactoryWithRequiredInput, getValueFromGetter, isGetter, Maybe } from '@dereekb/util'; import { Injector } from '@angular/core'; +import { timeoutStartWith } from '@dereekb/rxjs'; /** * authTransitionHookFn() configuration. The values are handled as: @@ -104,6 +105,8 @@ export function makeAuthTransitionHook(config: AuthTransitionHookConfig): Transi } const resultObs = decisionObs.pipe( + // after 10 seconds of no transition working, redirect with a false decision + timeoutStartWith(false as AuthTransitionDecision, 10 * 1000), first(), switchMap((decision: AuthTransitionDecision): Observable => { if (typeof decision === 'boolean') { diff --git a/packages/dbx-firebase/src/lib/auth/service/firebase.auth.service.ts b/packages/dbx-firebase/src/lib/auth/service/firebase.auth.service.ts index 7832bc2a2..274872382 100644 --- a/packages/dbx-firebase/src/lib/auth/service/firebase.auth.service.ts +++ b/packages/dbx-firebase/src/lib/auth/service/firebase.auth.service.ts @@ -1,4 +1,4 @@ -import { filterMaybe, isNot } from '@dereekb/rxjs'; +import { filterMaybe, isNot, timeoutStartWith } from '@dereekb/rxjs'; import { Injectable, Optional } from '@angular/core'; import { AuthUserState, DbxAuthService, loggedOutObsFromIsLoggedIn, loggedInObsFromIsLoggedIn, AuthUserIdentifier, authUserIdentifier } from '@dereekb/dbx-core'; import { Auth, authState, User, IdTokenResult, ParsedToken, GoogleAuthProvider, signInWithPopup, AuthProvider, PopupRedirectResolver, signInAnonymously, signInWithEmailAndPassword, UserCredential, FacebookAuthProvider, GithubAuthProvider, TwitterAuthProvider, createUserWithEmailAndPassword } from '@angular/fire/auth'; @@ -41,14 +41,7 @@ export const DEFAULT_DBX_FIREBASE_AUTH_SERVICE_DELEGATE: DbxFirebaseAuthServiceD export class DbxFirebaseAuthService implements DbxAuthService { private readonly _authState$: Observable> = authState(this.firebaseAuth); - readonly currentAuthUser$: Observable> = this._authState$.pipe( - timeout({ - first: 1000, - with: () => this._authState$.pipe(startWith(null)) - }), - distinctUntilChanged(), - shareReplay(1) - ); + readonly currentAuthUser$: Observable> = this._authState$.pipe(timeoutStartWith(null as Maybe, 1000), distinctUntilChanged(), shareReplay(1)); readonly currentAuthUserInfo$: Observable> = this.currentAuthUser$.pipe(map((x) => (x ? authUserInfoFromAuthUser(x) : undefined))); diff --git a/packages/firebase/src/lib/common/firestore/collection/collection.spec.ts b/packages/firebase/src/lib/common/firestore/collection/collection.spec.ts index 406b87338..0e5c1a600 100644 --- a/packages/firebase/src/lib/common/firestore/collection/collection.spec.ts +++ b/packages/firebase/src/lib/common/firestore/collection/collection.spec.ts @@ -1,4 +1,4 @@ -import { childFirestoreModelKeyPath } from '.'; +import { childFirestoreModelKeyPath, isFirestoreModelId, isFirestoreModelKey } from '.'; import { firestoreModelKeys, firestoreModelIdentity, firestoreModelKey, firestoreModelKeyPath } from './collection'; describe('firestoreModelIdentity()', () => { @@ -124,3 +124,35 @@ describe('childFirestoreModelKeyPath', () => { }); }); }); + +describe('isFirestoreModelId', () => { + it('should pass firestore model ids', () => { + expect(isFirestoreModelId('a')).toBe(true); + }); + + it('should fail on firestore model keys', () => { + expect(isFirestoreModelId('a/b')).toBe(false); + }); +}); + +describe('isFirestoreModelKey', () => { + it('should pass root firestore model keys', () => { + expect(isFirestoreModelKey('a/b')).toBe(true); + }); + + it('should pass child firestore model keys', () => { + expect(isFirestoreModelKey('a/b/c/d')).toBe(true); + }); + + it('should fail on firestore model ids', () => { + expect(isFirestoreModelKey('a')).toBe(false); + }); + + it('should fail on firestore model ids that end with a slash', () => { + expect(isFirestoreModelKey('a/')).toBe(false); + }); + + it('should fail on firestore model keys that point to a collection', () => { + expect(isFirestoreModelKey('a/b/c')).toBe(false); + }); +}); diff --git a/packages/firebase/src/lib/common/firestore/collection/collection.ts b/packages/firebase/src/lib/common/firestore/collection/collection.ts index 566987415..86a19ac50 100644 --- a/packages/firebase/src/lib/common/firestore/collection/collection.ts +++ b/packages/firebase/src/lib/common/firestore/collection/collection.ts @@ -182,6 +182,22 @@ export interface FirestoreModelIdentityRef { */ export type FirestoreModelId = string; +/** + * Firestore Model Id Regex + * + * https://stackoverflow.com/questions/52850099/what-is-the-reg-expression-for-firestore-constraints-on-document-ids + */ +export const FIRESTORE_MODEL_ID_REGEX = /^(?!\.\.?$)(?!.*__.*__)([^\s\/]{1,1500})$/; + +/** + * Returns true if the input string is a FirestoreModelId. + * + * @param input + */ +export function isFirestoreModelId(input: string | FirestoreModelId): input is FirestoreModelId { + return FIRESTORE_MODEL_ID_REGEX.test(input); +} + /** * Reference to a FirestoreModelId */ @@ -201,6 +217,25 @@ export interface FirestoreModelIdRef { */ export type FirestoreModelKey = ModelKey; +/** + * Firestore Model Key Regex that checks for pairs. + */ +export const FIRESTORE_MODEL_KEY_REGEX = /^(?:([^\s\/]+)\/([^\s\/]+))(?:\/(?:([^\s\/]+))\/(?:([^\s\/]+)))*$/; + +/** + * Firestore Model Key Regex that is more strict + */ +export const FIRESTORE_MODEL_KEY_REGEX_STRICT = /^(?:(?:(?!\.\.?$)(?!.*__.*__)([^\s\/]+))\/(?:(?!\.\.?$)(?!.*__.*__)([^\s\/]+))\/?)(?:\/(?:(?!\.\.?$)(?!.*__.*__)([^\s\/]+))\/(?:(?!\.\.?$)(?!.*__.*__)([^\s\/]+)))*$/; + +/** + * Returns true if the input string is a FirestoreModelKey. + * + * @param input + */ +export function isFirestoreModelKey(input: string | FirestoreModelKey): input is FirestoreModelKey { + return FIRESTORE_MODEL_KEY_REGEX.test(input); +} + /** * A part of a FirestoreModelKey. */ diff --git a/packages/firebase/src/lib/common/model/model/index.ts b/packages/firebase/src/lib/common/model/model/index.ts index 104a02724..93ac8608c 100644 --- a/packages/firebase/src/lib/common/model/model/index.ts +++ b/packages/firebase/src/lib/common/model/model/index.ts @@ -1,2 +1,3 @@ export * from './model.loader'; export * from './model.param'; +export * from './model.validator'; diff --git a/packages/firebase/src/lib/common/model/model/model.validator.spec.ts b/packages/firebase/src/lib/common/model/model/model.validator.spec.ts new file mode 100644 index 000000000..f28ce87bf --- /dev/null +++ b/packages/firebase/src/lib/common/model/model/model.validator.spec.ts @@ -0,0 +1,52 @@ +import { Expose } from 'class-transformer'; +import { IsOptional, validate } from 'class-validator'; +import { FirestoreModelId, FirestoreModelKey } from '../../firestore/collection/collection'; +import { IsFirestoreModelId, IsFirestoreModelKey } from './model.validator'; + +class TestModelClass { + @Expose() + @IsOptional() + @IsFirestoreModelId() + id!: FirestoreModelId; + + @Expose() + @IsOptional() + @IsFirestoreModelKey() + key!: FirestoreModelKey; +} + +describe('IsFirestoreModelKey', () => { + it('should pass valid keys', async () => { + const instance = new TestModelClass(); + instance.key = 'valid/key'; + + const result = await validate(instance); + expect(result.length).toBe(0); + }); + + it('should fail on invalid keys', async () => { + const instance = new TestModelClass(); + instance.key = 'invalid'; + + const result = await validate(instance); + expect(result.length).toBe(1); + }); +}); + +describe('IsFirestoreModelId', () => { + it('should pass valid ids', async () => { + const instance = new TestModelClass(); + instance.id = 'validid'; + + const result = await validate(instance); + expect(result.length).toBe(0); + }); + + it('should fail on invalid ids', async () => { + const instance = new TestModelClass(); + instance.id = 'invalid/id'; + + const result = await validate(instance); + expect(result.length).toBe(1); + }); +}); diff --git a/packages/firebase/src/lib/common/model/model/model.validator.ts b/packages/firebase/src/lib/common/model/model/model.validator.ts new file mode 100644 index 000000000..ac561f31b --- /dev/null +++ b/packages/firebase/src/lib/common/model/model/model.validator.ts @@ -0,0 +1,42 @@ +import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, ValidationOptions, registerDecorator } from 'class-validator'; +import { isFirestoreModelId, isFirestoreModelKey } from '../../firestore/collection/collection'; + +/** + * isFirestoreModelKey validator + */ +export function IsFirestoreModelKey(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'isFirestoreModelKey', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate: isFirestoreModelKey, + defaultMessage(args: ValidationArguments) { + return `"${args.value}" is not a FirestoreModelKey.`; + } + } + }); + }; +} + +/** + * isFirestoreModelId validator + */ +export function IsFirestoreModelId(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'isFirestoreModelId', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate: isFirestoreModelId, + defaultMessage(args: ValidationArguments) { + return `"${args.value}" is not a FirestoreModelId.`; + } + } + }); + }; +} diff --git a/packages/rxjs/src/lib/rxjs/value.ts b/packages/rxjs/src/lib/rxjs/value.ts index ff196fc1e..54efc0a90 100644 --- a/packages/rxjs/src/lib/rxjs/value.ts +++ b/packages/rxjs/src/lib/rxjs/value.ts @@ -113,9 +113,9 @@ export function switchMapMaybeObs(): OperatorFunction(defaultValue: GetterOrValue): MonoTypeOperatorFunction { +export function timeoutStartWith(defaultValue: GetterOrValue, first = 0): MonoTypeOperatorFunction { return (source: Observable) => { - return source.pipe(timeout({ first: 0, with: () => source.pipe(startWith(getValueFromGetter(defaultValue))) })); + return source.pipe(timeout({ first, with: () => source.pipe(startWith(getValueFromGetter(defaultValue))) })); }; }