Skip to content

Commit

Permalink
Share the auth operation state between both useAuth and useEmailPassw…
Browse files Browse the repository at this point in the history
…ordAuth
  • Loading branch information
takameyer committed Jun 7, 2023
1 parent dbda609 commit b6430da
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 89 deletions.
39 changes: 35 additions & 4 deletions packages/realm-react/src/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,20 @@
import { isEqual } from "lodash";
import React, { createContext, useContext, useLayoutEffect, useRef, useState } from "react";
import Realm from "realm";
import { AuthResult, OperationState } from "./types";

type AppContextValue = {
app: Realm.App | null;
authOperationStateHook: [AuthResult, React.Dispatch<React.SetStateAction<AuthResult>>] | null;
};

/**
* Create a context containing the Realm app. Should be accessed with the useApp hook.
*/
const AppContext = createContext<Realm.App | null>(null);
const AppContext = createContext<AppContextValue>({
app: null,
authOperationStateHook: null,
});

/**
* Props for the AppProvider component. These replicate the options which
Expand Down Expand Up @@ -59,6 +68,13 @@ export const AppProvider: React.FC<AppProviderProps> = ({

const [app, setApp] = useState<Realm.App>(() => new Realm.App(configuration.current));

const [authOpResult, setAuthOpResult] = useState<AuthResult>({
state: OperationState.NotStarted,
pending: false,
success: false,
error: undefined,
});

// Support for a possible change in configuration
if (!isEqual(appProps, configuration.current)) {
configuration.current = appProps;
Expand All @@ -80,7 +96,11 @@ export const AppProvider: React.FC<AppProviderProps> = ({
}
}, [appRef, app, logLevel]);

return <AppContext.Provider value={app}>{children}</AppContext.Provider>;
return (
<AppContext.Provider value={{ app, authOperationStateHook: [authOpResult, setAuthOpResult] }}>
{children}
</AppContext.Provider>
);
};

/**
Expand All @@ -92,10 +112,21 @@ export const useApp = <
FunctionsFactoryType extends Realm.DefaultFunctionsFactory,
CustomDataType extends Record<string, unknown>,
>(): Realm.App<FunctionsFactoryType, CustomDataType> => {
const app = useContext(AppContext);
const { app } = useContext(AppContext);

if (app === null) {
if (!app) {
throw new Error("No app found. Did you forget to wrap your component in an <AppProvider>?");
}
return app as Realm.App<FunctionsFactoryType, CustomDataType>;
};

export const useAuthResult = () => {
const { authOperationStateHook } = useContext(AppContext);

if (!authOperationStateHook) {
throw new Error(
"Auth operation statue could not be determined. Did you forget to wrap your component in an <AppProvider>?",
);
}
return authOperationStateHook;
};
40 changes: 11 additions & 29 deletions packages/realm-react/src/useAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@
//
////////////////////////////////////////////////////////////////////////////

import { useCallback, useState } from "react";
import { useApp } from "./AppProvider";
import { useCallback } from "react";
import { useApp, useAuthResult } from "./AppProvider";
import { AuthError, AuthResult, OperationState } from "./types";
import { Realm } from "realm";
import { useAuthOperation } from "./useAuthOperation";

/**
* Hook providing operations and corresponding state for authenticating with a
* Realm app.
*
* The {@link AuthResult} states returned from this hook are "global" for all
* components under a given RealmAppProvider, as only one operation can be in progress
* components under a given AppProvider, as only one operation can be in progress
* at a given time (i.e. we will store the states on the context). This means that,
* for example, multiple components can use the `useAuth` hook to access
* `loginResult.pending` to render a spinner when login is in progress, without
Expand Down Expand Up @@ -108,7 +109,7 @@ interface UseAuth {

/**
* The {@link AuthResult} of the current (or last) login operation performed
* for this `RealmAppContext`. There is one {@link AuthResult} for all `login`
* for this hook. There is one {@link AuthResult} for all `login`
* operations within a given `RealmAppProvider` context, as only one login can
* be in progress at a time (e.g. the {@link AuthResult} of `loginUser` from
* `useEmailPasswordAuth` is also represented by this).
Expand All @@ -118,32 +119,13 @@ interface UseAuth {

export function useAuth(): UseAuth {
const app = useApp();
const [result, setResult] = useState<AuthResult>({
state: OperationState.NotStarted,
pending: false,
success: false,
error: undefined,
});
const [result, setResult] = useAuthResult();

const logIn = useCallback(
(credentials: Realm.Credentials): Promise<Realm.User | void> => {
if (result.state === OperationState.Pending) {
return Promise.reject("Another auth operation is already in progress.");
}
setResult({ state: OperationState.Pending, pending: true, success: false, error: undefined });
return app.logIn(credentials).then(
(user) => {
setResult({ state: OperationState.Success, pending: false, success: true, error: undefined });
return user;
},
(error) => {
const authError = new AuthError(error);
setResult({ state: OperationState.Error, pending: false, success: false, error: authError });
},
);
},
[app, result, setResult],
);
const logIn = useAuthOperation({
result,
setResult,
operation: (credentials: Realm.Credentials) => app.logIn(credentials),
});

const logInWithAnonymous = useCallback(() => {
return logIn(Realm.Credentials.anonymous());
Expand Down
53 changes: 53 additions & 0 deletions packages/realm-react/src/useAuthOperation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
////////////////////////////////////////////////////////////////////////////
//
// Copyright 2023 Realm Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////

import { useCallback } from "react";
import { AuthError, AuthResult, OperationState } from "./types";

export function useAuthOperation<Args extends unknown[], Result>({
result,
setResult,
operation,
onSuccess = () => undefined,
}: {
result: AuthResult;
setResult: (value: React.SetStateAction<AuthResult>) => void;
operation: (...args: Args) => Promise<Result | void>;
onSuccess?: (...args: Args) => void;
}) {
return useCallback<(...args: Args) => ReturnType<typeof operation>>(
(...args) => {
if (result.pending) {
return Promise.reject("Another authentication operation is already in progress");
}

setResult({ state: OperationState.Pending, pending: true, success: false, error: undefined });
return operation(...args)
.then((res) => {
setResult({ state: OperationState.Success, pending: false, success: true, error: undefined });
onSuccess(...args);
return res;
})
.catch((error) => {
const authError = new AuthError(error);
setResult({ state: OperationState.Error, pending: false, success: false, error: authError });
});
},
[result, setResult, operation, onSuccess],
);
}
76 changes: 20 additions & 56 deletions packages/realm-react/src/useEmailPasswordAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,29 @@
//
////////////////////////////////////////////////////////////////////////////

import { useCallback, useEffect, useState } from "react";
import { useApp } from "./AppProvider";
import { AuthError, AuthResult, OperationState } from "./types";
import { Realm, App, Object, User, Credentials } from "realm";

import { useApp, useAuthResult } from "./AppProvider";
import { AuthResult } from "./types";
import { Realm, User, Credentials } from "realm";
import { useAuthOperation } from "./useAuthOperation";

/**
* Hook providing operations and corresponding state for authenticating with a
* Realm app with Email/Password. It also contains operations related to
* Email/Password authentication, such as resetting password and confirming a user.
*
* The {@link AuthResult} states returned from this hook are "global" for all
* components under a given AppProvider, as only one operation can be in progress
* at a given time (i.e. we will store the states on the context). This means that,
* for example, multiple components can use the `useAuth` hook to access
* `result.pending` to render a spinner when login is in progress, without
* needing to pass that state around or store it somewhere global in their app
* code.
*/
interface UseEmailPasswordAuth {
/**
* Convenience function to login a user with an email and password - users
* could also call `logIn(Realm.Credentials.emailPassword(email, password)).
*
* TODO: Does it make sense to have this convenience function? Should we add
* convenience functions for other/all auth types if so?
*
* @returns A `Realm.User` instance for the logged in user.
*/
logIn(args: { email: string; password: string }): Promise<Realm.User | void>;
Expand All @@ -49,9 +59,6 @@ interface UseEmailPasswordAuth {
loginAfterRegister?: boolean;
}): Promise<Realm.User | void>;

// TODO: verify that the following operations have the same error return type as
// the ones above (i.e. that `AuthResult` is the correct type for their results).

/**
* Confirm a user's account by providing the `token` and `tokenId` received.
*
Expand Down Expand Up @@ -103,63 +110,20 @@ interface UseEmailPasswordAuth {

/**
* Log out the current user.
*
* @returns A `Realm.User` instance for the logged in user.
* @throws if another operation is already in progress for this `useAuth` instance.
* @throws if there is an error logging out.
*/
logOut(): Promise<void>;

/**
* The {@link AuthResult} of the current (or last) operation performed for
* this `RealmAppContext`.
* this hook.
*/
result: AuthResult;
}

function useAuthOperation<Args extends unknown[], Result>({
result,
setResult,
operation,
onSuccess = () => undefined,
}: {
result: AuthResult;
setResult: (value: React.SetStateAction<AuthResult>) => void;
operation: (...args: Args) => Promise<Result | void>;
onSuccess?: (...args: Args) => void;
}) {
return useCallback<(...args: Args) => ReturnType<typeof operation>>(
(...args) => {
if (result.pending) {
return Promise.reject("Another authentication operation is already in progress");
}

setResult({ state: OperationState.Pending, pending: true, success: false, error: undefined });
return operation(...args)
.then((res) => {
setResult({ state: OperationState.Success, pending: false, success: true, error: undefined });
onSuccess(...args);
return res;
})
.catch((error) => {
const authError = new AuthError(error);
setResult({ state: OperationState.Error, pending: false, success: false, error: authError });
});
},
[result, setResult, operation, onSuccess],
);
}

export function useEmailPasswordAuth(): UseEmailPasswordAuth {
const [result, setResult] = useAuthResult();
const app = useApp();

const [result, setResult] = useState<AuthResult>({
state: OperationState.NotStarted,
pending: false,
success: false,
error: undefined,
});

const logIn = useAuthOperation<[{ email: string; password: string }], User>({
result,
setResult,
Expand Down

0 comments on commit b6430da

Please sign in to comment.