From bce951092325e0e8cc34b5233121d0e555294b8c Mon Sep 17 00:00:00 2001 From: Scott Prue Date: Wed, 18 Nov 2020 14:19:04 -0500 Subject: [PATCH] v3.8.0 (#1025) * feat(auth): enable claims without userProfile (#1008) - @rscotten * fix(types): add arguments to types for onAuthStateChanged (#1018) - @AlexanderArvidsson * fix(auth): dispatch proper error on reset password (#1016) - @djejaquino * fix(types) move static firestore interface to where it's implemented (#1013) - @zozoens31 * feat(auth): add applyActionCode method (#994) - @komachi Co-authored-by: Richard Scotten Co-authored-by: Alexander Arvidsson Co-authored-by: Davi Aquino Co-authored-by: Cyrille Corpet Co-authored-by: Anton Nesterov --- docs/auth.md | 22 ++++- index.d.ts | 120 ++++++++++++++--------- src/actions/auth.js | 39 +++++++- src/createFirebaseInstance.js | 10 ++ test/unit/actions/auth.spec.js | 36 ++++++- test/unit/createFirebaseInstance.spec.js | 29 +++++- test/utils.js | 6 ++ 7 files changed, 204 insertions(+), 58 deletions(-) diff --git a/docs/auth.md b/docs/auth.md index fd54e8ded..390ca3dd8 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -22,7 +22,7 @@ If you need access to methods that are not available at the top level, you can a Firebase has a secure way of identifying and making claims about users with [custom claims](https://firebase.google.com/docs/auth/admin/custom-claims). This is a good way to provide roles for users. -If `enableClaims` config option is used along with `userProfile` you will find custom claims in `state.firebase.profile.token.claims`. +If `enableClaims` config option is used you will find custom claims in `state.firebase.profile.token.claims`. **Note**: If a claim is added to a user who is already logged in those changes will not necessarily be propagated to the client. In order to assure the change is observed, use a `refreshToken` property in your `userProfile` collection and update it's value after the custom claim has been added. Because `react-redux-firebase` watches for profile changes, the custom claim will be fetched along with the `refreshToken` update. @@ -309,6 +309,26 @@ props.firebase.verifyPasswordResetCode('some reset code') [**Promise**][promise-url] - Email associated with reset code +## applyActionCode(code) + +Applies action code + +Calls Firebase's `firebase.auth().applyActionCode()`. If there is an error, it is added into redux state under `state.firebase.authError`. + +##### Examples + +```js +props.firebase.applyActionCode('some verification code') +``` + +##### Parameters + +- `code` [**String**][string-url] - Verification code + +##### Returns + +[**Promise**][promise-url] - Resolves on end + ## signInWithPhoneNumber(code) Signs in using a phone number in an async pattern (i.e. requires calling a second method). Calls Firebase's [`firebase.auth().signInWithPhoneNumber()`](https://firebase.google.com/docs/reference/js/firebase.auth.Auth#signInWithPhoneNumber). If there is an error, it is added into redux state under `state.firebase.authError`. diff --git a/index.d.ts b/index.d.ts index 9e7ff7cc8..410aa67d1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -21,7 +21,7 @@ type FileOrBlob = T extends File ? File : Blob export interface InferableComponentEnhancerWithProps< TInjectedProps, TNeedsProps -> { + > {

( component: React.ComponentType

): React.ComponentType & TNeedsProps> @@ -163,12 +163,12 @@ interface FirebaseDatabaseService { */ interface BaseExtendedFirebaseInstance extends DatabaseTypes.FirebaseDatabase, - FirebaseDatabaseService, - ExtendedAuthInstance, - ExtendedStorageInstance { + FirebaseDatabaseService, + ExtendedAuthInstance, + ExtendedStorageInstance { initializeAuth: VoidFunction - firestore: () => ExtendedFirestoreInstance + firestore: (() => ExtendedFirestoreInstance) & FirestoreStatics dispatch: Dispatch @@ -347,7 +347,7 @@ type OptionalOverride = b extends keyof T ? P : {}; type OptionalPick = Pick type ExtendedFirebaseInstance = BaseExtendedFirebaseInstance & OptionalPick - + /** * Create an extended firebase instance that has methods attached * which dispatch redux actions. @@ -381,12 +381,12 @@ export type QueryParamOptions = QueryParamOption | string[] export interface ReactReduxFirebaseQuerySetting { path: string type?: - | 'value' - | 'once' - | 'child_added' - | 'child_removed' - | 'child_changed' - | 'child_moved' + | 'value' + | 'once' + | 'child_added' + | 'child_removed' + | 'child_changed' + | 'child_moved' queryParams?: QueryParamOptions storeAs?: string } @@ -479,8 +479,7 @@ export type ReduxFirestoreQueriesFunction = ( * @see https://github.com/prescottprue/redux-firestore#api */ interface ExtendedFirestoreInstance - extends FirestoreTypes.FirebaseFirestore, - FirestoreStatics { + extends FirestoreTypes.FirebaseFirestore { /** * Get data from firestore. * @see https://github.com/prescottprue/redux-firestore#get @@ -596,19 +595,19 @@ interface CreateUserCredentials { type Credentials = | CreateUserCredentials | { - provider: 'facebook' | 'google' | 'twitter' | 'github' | 'microsoft.com' | 'apple.com' | 'yahoo.com' - type: 'popup' | 'redirect' - scopes?: string[] - } + provider: 'facebook' | 'google' | 'twitter' | 'github' | 'microsoft.com' | 'apple.com' | 'yahoo.com' + type: 'popup' | 'redirect' + scopes?: string[] + } | AuthTypes.AuthCredential | { - token: string - profile: Object - } + token: string + profile: Object + } | { - phoneNumber: string - applicationVerifier: AuthTypes.ApplicationVerifier - } + phoneNumber: string + applicationVerifier: AuthTypes.ApplicationVerifier + } type UserProfile

= P @@ -670,6 +669,9 @@ interface ExtendedAuthInstance { // https://react-redux-firebase.com/docs/auth.html#verifypasswordresetcodecode verifyPasswordResetCode: AuthTypes.FirebaseAuth['verifyPasswordResetCode'] + // https://react-redux-firebase.com/docs/auth.html#applyactioncode + applyActionCode: AuthTypes.FirebaseAuth['applyActionCode'] + /** * Signs in using a phone number in an async pattern (i.e. requires calling a second method). * @param phoneNumber - Update to be auth object @@ -802,25 +804,25 @@ interface ExtendedStorageInstance { */ export interface UploadFileOptions { name?: - | string - | (( - file: FileOrBlob, - internalFirebase: WithFirebaseProps['firebase'], - uploadConfig: { - path: string - file: FileOrBlob - dbPath?: string - options?: UploadFileOptions - } - ) => string) + | string + | (( + file: FileOrBlob, + internalFirebase: WithFirebaseProps['firebase'], + uploadConfig: { + path: string + file: FileOrBlob + dbPath?: string + options?: UploadFileOptions + } + ) => string) documentId?: - | string - | (( - uploadRes: StorageTypes.UploadTaskSnapshot, - firebase: WithFirebaseProps['firebase'], - metadata: StorageTypes.UploadTaskSnapshot['metadata'], - downloadURL: string - ) => string) + | string + | (( + uploadRes: StorageTypes.UploadTaskSnapshot, + firebase: WithFirebaseProps['firebase'], + metadata: StorageTypes.UploadTaskSnapshot['metadata'], + downloadURL: string + ) => string) useSetForMetadata?: boolean metadata?: StorageTypes.UploadMetadata metadataFactory?: ( @@ -1046,7 +1048,7 @@ interface ReactReduxFirebaseConfig { enableRedirectHandling: boolean firebaseStateName: string logErrors: boolean - onAuthStateChanged: (user: AuthTypes.User | null) => void + onAuthStateChanged: (user: AuthTypes.User | null, _firebase: any, dispatch: Dispatch) => void presence: any preserveOnEmptyAuthChange: any preserveOnLogout: any @@ -1095,8 +1097,8 @@ export interface ReduxFirestoreConfig { // https://github.com/prescottprue/redux-firestore#allowmultiplelisteners allowMultipleListeners: - | ((listenerToAttach: any, currentListeners: any) => boolean) - | boolean + | ((listenerToAttach: any, currentListeners: any) => boolean) + | boolean // https://github.com/prescottprue/redux-firestore#preserveondelete preserveOnDelete: null | object @@ -1106,8 +1108,8 @@ export interface ReduxFirestoreConfig { // https://github.com/prescottprue/redux-firestore#onattemptcollectiondelete onAttemptCollectionDelete: - | null - | ((queryOption: any, dispatch: any, firebase: any) => void) + | null + | ((queryOption: any, dispatch: any, firebase: any) => void) // https://github.com/prescottprue/redux-firestore#mergeordered mergeOrdered: boolean @@ -1200,7 +1202,7 @@ export namespace FirebaseReducer { export interface Reducer< ProfileType extends Record = {}, Schema extends Record = {} - > { + > { auth: AuthState profile: Profile authError: any @@ -1240,6 +1242,28 @@ export namespace FirebaseReducer { export type Profile = { isLoaded: boolean isEmpty: boolean + token?: { + token: string + expirationTime: string + authTime: string + issuedAtTime: string + signInProvider: string + signInSecondFactor: any + claims: { + name: string + picture: string + iss: string + aud: string + auth_time: number + user_id: string + sub: string + iat: number + exp: number + email: string + email_verified: boolean + [key: string]: any + }; + } } & ProfileType export namespace firebaseStateReducer { diff --git a/src/actions/auth.js b/src/actions/auth.js index e70f207cc..4ead9deec 100644 --- a/src/actions/auth.js +++ b/src/actions/auth.js @@ -264,6 +264,16 @@ export const watchUserProfile = (dispatch, firebase) => { 'Real Time Database or Firestore must be included to enable user profile' ) } + } else if (enableClaims) { + firebase._.profileWatch = firebase + .auth() + .currentUser.getIdTokenResult(true) + .then((token) => { + dispatch({ + type: actionTypes.SET_PROFILE, + profile: { token } + }) + }) } } @@ -738,10 +748,10 @@ export const resetPassword = (dispatch, firebase, email) => { if (err) { switch (err.code) { case 'auth/user-not-found': - dispatchLoginError( - dispatch, - new Error('The specified user account does not exist.') - ) + dispatchLoginError(dispatch, { + ...err, + message: 'The specified user account does not exist.' + }) break default: dispatchLoginError(dispatch, err) @@ -821,6 +831,27 @@ export const verifyPasswordResetCode = (dispatch, firebase, code) => { }) } +/** + * Apply a verification code sent via email or other mechanism + * @param {Function} dispatch - Action dispatch function + * @param {object} firebase - Internal firebase object + * @param {string} code - Verification code + * @returns {Promise} Resolves after applying verification code + * @private + */ +export const applyActionCode = (dispatch, firebase, code) => { + dispatchLoginError(dispatch, null) + return firebase + .auth() + .applyActionCode(code) + .catch((err) => { + if (err) { + dispatchLoginError(dispatch, err) + } + return Promise.reject(err) + }) +} + /** * Update user profile * @param {Function} dispatch - Action dispatch function diff --git a/src/createFirebaseInstance.js b/src/createFirebaseInstance.js index e1fe5105d..249d9e445 100644 --- a/src/createFirebaseInstance.js +++ b/src/createFirebaseInstance.js @@ -456,6 +456,15 @@ export default function createFirebaseInstance(firebase, configs, dispatch) { const verifyPasswordResetCode = (code) => authActions.verifyPasswordResetCode(dispatch, firebase, code) + /** + * Apply verification code + * @param {string} code - Verification code + * @returns {Promise} Resolves on success + * @see https://react-redux-firebase.com/docs/api/firebaseInstance.html#applyactioncode + */ + const applyActionCode = (code) => + authActions.applyActionCode(dispatch, firebase, code) + /** * Update user profile on Firebase Real Time Database or * Firestore (if `useFirestoreForProfile: true` config included). @@ -584,6 +593,7 @@ export default function createFirebaseInstance(firebase, configs, dispatch) { resetPassword, confirmPasswordReset, verifyPasswordResetCode, + applyActionCode, watchEvent, unWatchEvent, reloadAuth, diff --git a/test/unit/actions/auth.spec.js b/test/unit/actions/auth.spec.js index ce7d611c5..30d8e8e01 100644 --- a/test/unit/actions/auth.spec.js +++ b/test/unit/actions/auth.spec.js @@ -18,7 +18,8 @@ import { updateEmail, resetPassword, confirmPasswordReset, - verifyPasswordResetCode + verifyPasswordResetCode, + applyActionCode } from 'actions/auth' import { cloneDeep } from 'lodash' import { actionTypes } from 'constants' // eslint-disable-line node/no-deprecated-api @@ -203,6 +204,23 @@ describe('Actions: Auth -', () => { expect(firebase._.profileWatch).to.be.a.function }) + it('for only the custom claims token', () => { + const fb = firebaseWithConfig({ userProfile: null, enableClaims: true }) + fb.auth = () => ({ + currentUser: { + getIdTokenResult: (bool) => ({ + then: (func) => func('testToken') + }) + } + }) + watchUserProfile(functionSpy, fb) + expect(firebase._.profileWatch).to.be.a.function + expect(functionSpy).to.be.calledWith({ + type: actionTypes.SET_PROFILE, + profile: { token: 'testToken' } + }) + }) + describe('populates -', () => { it('skips populating data into profile by default', () => { firebase._.config.profileParamsToPopulate = 'role:roles' @@ -592,6 +610,22 @@ describe('Actions: Auth -', () => { }) }) + describe('applyActionCode', () => { + it('resolves for valid code', async () => { + res = await applyActionCode(dispatch, fakeFirebase, 'test') + // "success" indicates successful pas through of stub function + expect(res).to.equal('success') + }) + + it('throws for invalid reset code', async () => { + try { + res = await applyActionCode(dispatch, fakeFirebase, 'error') + } catch (err) { + expect(err.code).to.be.a.string + } + }) + }) + describe('updateProfile', () => { it('dispatches PROFILE_UPDATE_START with profile', async () => { const payload = null diff --git a/test/unit/createFirebaseInstance.spec.js b/test/unit/createFirebaseInstance.spec.js index ad60db4f8..b25f322e5 100644 --- a/test/unit/createFirebaseInstance.spec.js +++ b/test/unit/createFirebaseInstance.spec.js @@ -594,16 +594,37 @@ describe('createFirebaseInstance', () => { ) expect(firebaseInstance.verifyPasswordResetCode).to.be.a.function - const email = 'test@test.com' - const password = 'asdfasdf1' + const code = 'asdfasdf1' await firebaseInstance.verifyPasswordResetCode({ - email, - password + code }) expect(verifyPasswordResetCodeSpy).to.have.been.calledOnce }) }) + describe('applyActionCode method', () => { + it('calls firebase auth applyActionCode', async () => { + const dispatchSpy = sinon.spy(() => {}) + const applyActionCodeSpy = sinon.spy(() => Promise.resolve({})) + const firebaseStub = { + auth: () => ({ applyActionCode: applyActionCodeSpy }), + _: firebase._ + } + const firebaseInstance = createFirebaseInstance( + firebaseStub, + {}, + dispatchSpy + ) + + expect(firebaseInstance.applyActionCode).to.be.a.function + const code = 'asdfasdf1' + await firebaseInstance.applyActionCode({ + code + }) + expect(applyActionCodeSpy).to.have.been.calledOnce + }) + }) + describe('updateProfile method', () => { it('calls firebase database update', async () => { const dispatchSpy = sinon.spy(() => {}) diff --git a/test/utils.js b/test/utils.js index c75ac09f2..b0088fa09 100644 --- a/test/utils.js +++ b/test/utils.js @@ -364,6 +364,12 @@ export const fakeFirebase = { email: 'test@test.com', providerData: [{}] }) + : Promise.resolve('success'), + applyActionCode: (code) => + code === 'error' + ? Promise.reject(new Error('some')) + ? Promise.reject({ code: 'asdfasdf' }) // eslint-disable-line prefer-promise-reject-errors + : Promise.resolve() : Promise.resolve('success') }), storage: () => ({