Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
"type": "git",
"url": "https://github.com/PropelAuth/react"
},
"version": "2.0.32",
"version": "2.1.0",
"license": "MIT",
"keywords": [
"auth",
"react",
"user"
],
"dependencies": {
"@propelauth/javascript": "^2.0.23",
"@propelauth/javascript": "^2.0.24",
"hoist-non-react-statics": "^3.3.2",
"utility-types": "^3.10.0"
},
Expand Down Expand Up @@ -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"
},
Expand Down
53 changes: 43 additions & 10 deletions src/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AccessTokenForActiveOrg,
AuthenticationInfo,
IAuthClient,
RedirectToAccountOptions,
RedirectToCreateOrgOptions,
RedirectToLoginOptions,
Expand Down Expand Up @@ -46,24 +47,52 @@ export interface InternalAuthState {
defaultDisplayIfLoggedOut?: React.ReactElement
}

export type AuthProviderProps = {
authUrl: string
type BaseAuthProviderProps = {
defaultDisplayWhileLoading?: React.ReactElement
defaultDisplayIfLoggedOut?: React.ReactElement
/**
* getActiveOrgFn is deprecated. Use `useActiveOrgV2` instead.
*/
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<AuthProviderProps, "defaultDisplayWhileLoading" | "defaultDisplayIfLoggedOut"> {
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<InternalAuthState | undefined>(undefined)

type AuthInfoState = {
Expand Down Expand Up @@ -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(() => {
Expand Down
64 changes: 55 additions & 9 deletions src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ it("redirectToLoginPage calls into the client", async () => {
return <div>Finished</div>
}
render(
<AuthProvider authUrl={AUTH_URL}>
<AuthProvider client={mockClient}>
<Component />
</AuthProvider>
)
Expand All @@ -341,7 +341,7 @@ it("redirectToSignupPage calls into the client", async () => {
return <div>Finished</div>
}
render(
<AuthProvider authUrl={AUTH_URL}>
<AuthProvider client={mockClient}>
<Component />
</AuthProvider>
)
Expand All @@ -356,7 +356,7 @@ it("redirectToCreateOrgPage calls into the client", async () => {
return <div>Finished</div>
}
render(
<AuthProvider authUrl={AUTH_URL}>
<AuthProvider client={mockClient}>
<Component />
</AuthProvider>
)
Expand All @@ -371,7 +371,7 @@ it("redirectToAccountPage calls into the client", async () => {
return <div>Finished</div>
}
render(
<AuthProvider authUrl={AUTH_URL}>
<AuthProvider client={mockClient}>
<Component />
</AuthProvider>
)
Expand All @@ -386,7 +386,7 @@ it("redirectToOrgPage calls into the client", async () => {
return <div>Finished</div>
}
render(
<AuthProvider authUrl={AUTH_URL}>
<AuthProvider client={mockClient}>
<Component />
</AuthProvider>
)
Expand All @@ -401,14 +401,49 @@ it("logout calls into the client", async () => {
return <div>Finished</div>
}
render(
<AuthProvider authUrl={AUTH_URL}>
<AuthProvider client={mockClient}>
<Component />
</AuthProvider>
)
await waitFor(() => screen.getByText("Finished"))
expect(mockClient.logout).toBeCalled()
})

it("external client is not destroyed on unmount", async () => {
const { unmount } = render(
<AuthProvider client={mockClient}>
<div>Finished</div>
</AuthProvider>
)
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 <div>Loading...</div>
}
expect(authInfo.accessToken).toBe(authenticationInfo.accessToken)
expect(authInfo.user).toStrictEqual(authenticationInfo.user)
expect(authInfo.isLoggedIn).toBe(true)
return <div>Finished</div>
}

render(
<AuthProvider client={mockClient}>
<Component />
</AuthProvider>
)

await waitFor(() => screen.getByText("Finished"))
})

it("when client logs out, authInfo is refreshed", async () => {
const initialAuthInfo = createAuthenticationInfo()
mockClient.getAuthenticationInfoOrNull.mockReturnValueOnce(initialAuthInfo).mockReturnValueOnce(null)
Expand Down Expand Up @@ -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(),
Expand All @@ -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() {
Expand Down
4 changes: 3 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
88 changes: 70 additions & 18 deletions src/useClientRef.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientRef | null>(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<ClientRef | null>(
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 }
}
Expand All @@ -49,6 +94,13 @@ export const useClientRefCallback = <I extends unknown[], O>(
(...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)
Expand Down