diff --git a/package.json b/package.json index aaf111b..33c1f6b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "git", "url": "https://github.com/PropelAuth/react" }, - "version": "2.0.32", + "version": "2.1.0", "license": "MIT", "keywords": [ "auth", @@ -13,7 +13,7 @@ "user" ], "dependencies": { - "@propelauth/javascript": "^2.0.23", + "@propelauth/javascript": "^2.0.24", "hoist-non-react-statics": "^3.3.2", "utility-types": "^3.10.0" }, @@ -62,7 +62,7 @@ "build:types": "tsc --emitDeclarationOnly", "build:js": "rollup -c", "lint": "eslint --ext .ts,.tsx .", - "build": "npm run test && npm run lint && npm run build:types && npm run build:js", + "build": "npm run test && npm run build:types && npm run build:js", "test": "npm run lint && jest --silent", "prepublishOnly": "npm run build" }, diff --git a/src/AuthContext.tsx b/src/AuthContext.tsx index 0a8ef77..2570267 100644 --- a/src/AuthContext.tsx +++ b/src/AuthContext.tsx @@ -1,6 +1,7 @@ import { AccessTokenForActiveOrg, AuthenticationInfo, + IAuthClient, RedirectToAccountOptions, RedirectToCreateOrgOptions, RedirectToLoginOptions, @@ -46,8 +47,7 @@ export interface InternalAuthState { defaultDisplayIfLoggedOut?: React.ReactElement } -export type AuthProviderProps = { - authUrl: string +type BaseAuthProviderProps = { defaultDisplayWhileLoading?: React.ReactElement defaultDisplayIfLoggedOut?: React.ReactElement /** @@ -55,15 +55,44 @@ export type AuthProviderProps = { */ getActiveOrgFn?: () => string | null children?: React.ReactNode +} + +type AuthProviderWithAuthUrl = BaseAuthProviderProps & { + authUrl: string minSecondsBeforeRefresh?: number + client?: never +} + +type AuthProviderWithClient = BaseAuthProviderProps & { + client: IAuthClient + authUrl?: never + minSecondsBeforeRefresh?: never } -export interface RequiredAuthProviderProps - extends Omit { +export type AuthProviderProps = AuthProviderWithAuthUrl | AuthProviderWithClient + +type BaseRequiredAuthProviderProps = Omit< + BaseAuthProviderProps, + "defaultDisplayWhileLoading" | "defaultDisplayIfLoggedOut" +> & { displayWhileLoading?: React.ReactElement displayIfLoggedOut?: React.ReactElement } +type RequiredAuthProviderWithAuthUrl = BaseRequiredAuthProviderProps & { + authUrl: string + minSecondsBeforeRefresh?: number + client?: never +} + +type RequiredAuthProviderWithClient = BaseRequiredAuthProviderProps & { + client: IAuthClient + authUrl?: never + minSecondsBeforeRefresh?: never +} + +export type RequiredAuthProviderProps = RequiredAuthProviderWithAuthUrl | RequiredAuthProviderWithClient + export const AuthContext = React.createContext(undefined) type AuthInfoState = { @@ -103,18 +132,22 @@ function authInfoStateReducer(_state: AuthInfoState, action: AuthInfoStateAction export const AuthProvider = (props: AuthProviderProps) => { const { - authUrl, - minSecondsBeforeRefresh, getActiveOrgFn: deprecatedGetActiveOrgFn, children, defaultDisplayWhileLoading, defaultDisplayIfLoggedOut, } = props + + const clientRefProps = + "client" in props && props.client + ? { client: props.client } + : { authUrl: props.authUrl!, minSecondsBeforeRefresh: props.minSecondsBeforeRefresh } + + const authUrl = + "client" in clientRefProps ? clientRefProps.client!.getAuthOptions().authUrl : clientRefProps.authUrl + const [authInfoState, dispatch] = useReducer(authInfoStateReducer, initialAuthInfoState) - const { clientRef, accessTokenChangeCounter } = useClientRef({ - authUrl, - minSecondsBeforeRefresh, - }) + const { clientRef, accessTokenChangeCounter } = useClientRef(clientRefProps) // Refresh the token when the user has logged in or out useEffect(() => { diff --git a/src/index.test.js b/src/index.test.js index 2b56a9e..f78e55d 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -326,7 +326,7 @@ it("redirectToLoginPage calls into the client", async () => { return
Finished
} render( - + ) @@ -341,7 +341,7 @@ it("redirectToSignupPage calls into the client", async () => { return
Finished
} render( - + ) @@ -356,7 +356,7 @@ it("redirectToCreateOrgPage calls into the client", async () => { return
Finished
} render( - + ) @@ -371,7 +371,7 @@ it("redirectToAccountPage calls into the client", async () => { return
Finished
} render( - + ) @@ -386,7 +386,7 @@ it("redirectToOrgPage calls into the client", async () => { return
Finished
} render( - + ) @@ -401,7 +401,7 @@ it("logout calls into the client", async () => { return
Finished
} render( - + ) @@ -409,6 +409,41 @@ it("logout calls into the client", async () => { expect(mockClient.logout).toBeCalled() }) +it("external client is not destroyed on unmount", async () => { + const { unmount } = render( + +
Finished
+
+ ) + await waitFor(() => screen.getByText("Finished")) + unmount() + expect(mockClient.destroy).not.toHaveBeenCalled() +}) + +it("useAuthInfo returns auth info from external client", async () => { + const authenticationInfo = createAuthenticationInfo() + mockClient.getAuthenticationInfoOrNull.mockReturnValue(authenticationInfo) + + const Component = () => { + const authInfo = useAuthInfo() + if (authInfo.loading) { + return
Loading...
+ } + expect(authInfo.accessToken).toBe(authenticationInfo.accessToken) + expect(authInfo.user).toStrictEqual(authenticationInfo.user) + expect(authInfo.isLoggedIn).toBe(true) + return
Finished
+ } + + render( + + + + ) + + await waitFor(() => screen.getByText("Finished")) +}) + it("when client logs out, authInfo is refreshed", async () => { const initialAuthInfo = createAuthenticationInfo() mockClient.getAuthenticationInfoOrNull.mockReturnValueOnce(initialAuthInfo).mockReturnValueOnce(null) @@ -583,9 +618,18 @@ it("AuthProviderForTesting can be used with useAuthInfo", async () => { await waitFor(() => screen.getByText("Finished")) }) +const AUTH_URL = "authUrl" + function createMockClient() { return { getAuthenticationInfoOrNull: jest.fn(), + getAuthOptions: jest.fn().mockReturnValue({ + authUrl: AUTH_URL, + enableBackgroundTokenRefresh: true, + minSecondsBeforeRefresh: 120, + disableRefreshOnFocus: false, + skipInitialFetch: true, + }), logout: jest.fn(), redirectToSignupPage: jest.fn(), redirectToLoginPage: jest.fn(), @@ -600,10 +644,12 @@ function createMockClient() { } } -const AUTH_URL = "authUrl" - function expectCreateClientWasCalledCorrectly() { - expect(createClient).toHaveBeenCalledWith({ authUrl: AUTH_URL, enableBackgroundTokenRefresh: true, skipInitialFetch: true }) + expect(createClient).toHaveBeenCalledWith({ + authUrl: AUTH_URL, + enableBackgroundTokenRefresh: true, + skipInitialFetch: true, + }) } function createOrg() { diff --git a/src/index.tsx b/src/index.tsx index b903d39..c7ca4bb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,9 @@ -export { OrgMemberInfoClass, UserClass } from "@propelauth/javascript" +export { createClient, OrgMemberInfoClass, UserClass } from "@propelauth/javascript" export type { AccessHelper, AccessHelperWithOrg, + IAuthClient, + IAuthOptions, OrgHelper, OrgIdToOrgMemberInfo, OrgIdToOrgMemberInfoClass, diff --git a/src/useClientRef.tsx b/src/useClientRef.tsx index aba5652..6b2d22b 100644 --- a/src/useClientRef.tsx +++ b/src/useClientRef.tsx @@ -6,37 +6,82 @@ type ClientRef = { client: IAuthClient } -interface UseClientRefProps { +// Props when creating a new client internally +type UseClientRefCreateProps = { authUrl: string minSecondsBeforeRefresh?: number + client?: never } +// Props when using an externally-provided client +type UseClientRefExternalProps = { + client: IAuthClient + authUrl?: never + minSecondsBeforeRefresh?: never +} + +type UseClientRefProps = UseClientRefCreateProps | UseClientRefExternalProps + export const useClientRef = (props: UseClientRefProps) => { const [accessTokenChangeCounter, setAccessTokenChangeCounter] = useState(0) - const { authUrl, minSecondsBeforeRefresh } = props - // Use a ref to store the client so that it doesn't get recreated on every render - const clientRef = useRef(null) - if (clientRef.current === null) { - const client = createClient({ authUrl, enableBackgroundTokenRefresh: true, minSecondsBeforeRefresh, skipInitialFetch: true }) - client.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1)) - clientRef.current = { authUrl, client } - } + const externalClient = "client" in props ? props.client : undefined + const authUrl = "authUrl" in props ? props.authUrl : undefined + const minSecondsBeforeRefresh = "minSecondsBeforeRefresh" in props ? props.minSecondsBeforeRefresh : undefined - // If the authUrl changes, destroy the old client and create a new one + // Initialize ref immediately with external client (available during render) + // or null (will be set in useEffect for internally-created clients) + const clientRef = useRef( + externalClient ? { authUrl: externalClient.getAuthOptions().authUrl, client: externalClient } : null + ) + + // Effect for external client: set up observer useEffect(() => { - if (clientRef.current === null) { + if (!externalClient) { return - } else if (clientRef.current.authUrl === authUrl) { + } + + // Warning for disabled background refresh + const options = externalClient.getAuthOptions() + if (!options.enableBackgroundTokenRefresh) { + console.warn( + "[@propelauth/react] The provided client has enableBackgroundTokenRefresh disabled. " + + "This may cause authentication state to become stale." + ) + } + + const observer = () => setAccessTokenChangeCounter((x) => x + 1) + externalClient.addAccessTokenChangeObserver(observer) + + return () => { + externalClient.removeAccessTokenChangeObserver(observer) + } + }, [externalClient]) + + // Effect for internal client: create, set up observer, and manage lifecycle + useEffect(() => { + if (externalClient) { return - } else { - clientRef.current.client.destroy() + } - const newClient = createClient({ authUrl, enableBackgroundTokenRefresh: true, minSecondsBeforeRefresh, skipInitialFetch: true }) - newClient.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1)) - clientRef.current = { authUrl, client: newClient } + const client = createClient({ + authUrl: authUrl!, + enableBackgroundTokenRefresh: true, + minSecondsBeforeRefresh, + skipInitialFetch: true, + }) + + client.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1)) + + clientRef.current = { authUrl: client.getAuthOptions().authUrl, client } + + return () => { + client.destroy() + if (clientRef.current?.client === client) { + clientRef.current = null + } } - }, [authUrl]) + }, [externalClient, authUrl, minSecondsBeforeRefresh]) return { clientRef, accessTokenChangeCounter } } @@ -49,6 +94,13 @@ export const useClientRefCallback = ( (...inputs: I) => { const client = clientRef.current?.client if (!client) { + console.error( + "[@propelauth/react] Auth client is not initialized yet. " + + "The client is created in a useEffect, which runs after render. " + + "This error typically occurs when calling auth methods during component render. " + + "To fix this, either move auth calls to useEffect/event handlers, or create " + + "the client yourself with createClient() and pass it to AuthProvider via the 'client' prop." + ) throw new Error("Client is not initialized") } return callback(client)(...inputs)