Files
payload/packages/ui/src/providers/Auth/index.tsx
Jacob Fletcher 355bd12c61 chore: infer React context providers and prefer use (#11669)
As of [React 19](https://react.dev/blog/2024/12/05/react-19), context
providers no longer require the `<MyContext.Provider>` syntax and can be
rendered as `<MyContext>` directly. This will be deprecated in future
versions of React, which is now being caught by the
[`@eslint-react/no-context-provider`](https://eslint-react.xyz/docs/rules/no-context-provider)
ESLint rule.

Similarly, the [`use`](https://react.dev/reference/react/use) API is now
preferred over `useContext` because it is more flexible, for example
they can be called within loops and conditional statements. See the
[`@eslint-react/no-use-context`](https://eslint-react.xyz/docs/rules/no-use-context)
ESLint rule for more details.
2025-03-12 15:48:20 -04:00

330 lines
9.1 KiB
TypeScript

'use client'
import type { ClientUser, SanitizedPermissions, User } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { usePathname, useRouter } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import * as qs from 'qs-esm'
import React, { createContext, use, useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { stayLoggedInModalSlug } from '../../elements/StayLoggedIn/index.js'
import { useDebounce } from '../../hooks/useDebounce.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
import { useConfig } from '../Config/index.js'
import { useRouteTransition } from '../RouteTransition/index.js'
export type UserWithToken<T = ClientUser> = {
exp: number
token: string
user: T
}
export type AuthContext<T = ClientUser> = {
fetchFullUser: () => Promise<null | User>
logOut: () => Promise<boolean>
permissions?: SanitizedPermissions
refreshCookie: (forceRefresh?: boolean) => void
refreshCookieAsync: () => Promise<ClientUser>
refreshPermissions: () => Promise<void>
setPermissions: (permissions: SanitizedPermissions) => void
setUser: (user: null | UserWithToken<T>) => void
strategy?: string
token?: string
tokenExpiration?: number
user?: null | T
}
const Context = createContext({} as AuthContext)
const maxTimeoutTime = 2147483647
type Props = {
children: React.ReactNode
permissions?: SanitizedPermissions
user?: ClientUser | null
}
export function AuthProvider({
children,
permissions: initialPermissions,
user: initialUser,
}: Props) {
const [user, setUserInMemory] = useState<ClientUser | null>(initialUser)
const [tokenInMemory, setTokenInMemory] = useState<string>()
const [tokenExpiration, setTokenExpiration] = useState<number>()
const pathname = usePathname()
const router = useRouter()
const { config } = useConfig()
const {
admin: {
routes: { inactivity: logoutInactivityRoute },
user: userSlug,
},
routes: { admin: adminRoute, api: apiRoute },
serverURL,
} = config
const [permissions, setPermissions] = useState<SanitizedPermissions>(initialPermissions)
const { i18n } = useTranslation()
const { closeAllModals, openModal } = useModal()
const [lastLocationChange, setLastLocationChange] = useState(0)
const debouncedLocationChange = useDebounce(lastLocationChange, 10000)
const refreshTokenTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(null)
const { startRouteTransition } = useRouteTransition()
const id = user?.id
const redirectToInactivityRoute = useCallback(() => {
startRouteTransition(() =>
router.replace(
formatAdminURL({
adminRoute,
path: `${logoutInactivityRoute}${window.location.pathname.startsWith(adminRoute) ? `?redirect=${encodeURIComponent(window.location.pathname)}` : ''}`,
}),
),
)
closeAllModals()
}, [router, adminRoute, logoutInactivityRoute, closeAllModals, startRouteTransition])
const revokeTokenAndExpire = useCallback(() => {
setTokenInMemory(undefined)
setTokenExpiration(undefined)
clearTimeout(refreshTokenTimeoutRef.current)
}, [])
const setNewUser = useCallback(
(userResponse: null | UserWithToken) => {
if (userResponse?.user) {
setUserInMemory(userResponse.user)
setTokenInMemory(userResponse.token)
setTokenExpiration(userResponse.exp)
} else {
setUserInMemory(null)
revokeTokenAndExpire()
}
},
[revokeTokenAndExpire],
)
const refreshCookie = useCallback(
(forceRefresh?: boolean) => {
const now = Math.round(new Date().getTime() / 1000)
const remainingTime = (typeof tokenExpiration === 'number' ? tokenExpiration : 0) - now
if (forceRefresh || (tokenExpiration && remainingTime < 120)) {
refreshTokenTimeoutRef.current = setTimeout(() => {
async function refresh() {
try {
const request = await requests.post(
`${serverURL}${apiRoute}/${userSlug}/refresh-token?refresh`,
{
headers: {
'Accept-Language': i18n.language,
},
},
)
if (request.status === 200) {
const json = await request.json()
setNewUser(json)
} else {
setNewUser(null)
redirectToInactivityRoute()
}
} catch (e) {
toast.error(e.message)
}
}
void refresh()
}, 1000)
}
return () => {
clearTimeout(refreshTokenTimeoutRef.current)
}
},
[
apiRoute,
i18n.language,
redirectToInactivityRoute,
serverURL,
setNewUser,
tokenExpiration,
userSlug,
],
)
const refreshCookieAsync = useCallback(
async (skipSetUser?: boolean): Promise<ClientUser> => {
try {
const request = await requests.post(`${serverURL}${apiRoute}/${userSlug}/refresh-token`, {
headers: {
'Accept-Language': i18n.language,
},
})
if (request.status === 200) {
const json = await request.json()
if (!skipSetUser) {
setNewUser(json)
}
return json.user
}
setNewUser(null)
redirectToInactivityRoute()
return null
} catch (e) {
toast.error(`Refreshing token failed: ${e.message}`)
return null
}
},
[apiRoute, i18n.language, redirectToInactivityRoute, serverURL, setNewUser, userSlug],
)
const logOut = useCallback(async () => {
try {
await requests.post(`${serverURL}${apiRoute}/${user.collection}/logout`)
setNewUser(null)
revokeTokenAndExpire()
return true
} catch (e) {
toast.error(`Logging out failed: ${e.message}`)
return false
}
}, [apiRoute, revokeTokenAndExpire, serverURL, setNewUser, user])
const refreshPermissions = useCallback(
async ({ locale }: { locale?: string } = {}) => {
const params = qs.stringify(
{
locale,
},
{
addQueryPrefix: true,
},
)
try {
const request = await requests.get(`${serverURL}${apiRoute}/access${params}`, {
headers: {
'Accept-Language': i18n.language,
},
})
if (request.status === 200) {
const json: SanitizedPermissions = await request.json()
setPermissions(json)
} else {
throw new Error(`Fetching permissions failed with status code ${request.status}`)
}
} catch (e) {
toast.error(`Refreshing permissions failed: ${e.message}`)
}
},
[serverURL, apiRoute, i18n],
)
const fetchFullUser = React.useCallback(async () => {
try {
const request = await requests.get(`${serverURL}${apiRoute}/${userSlug}/me`, {
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
},
})
if (request.status === 200) {
const json: UserWithToken = await request.json()
const user = null
setNewUser(json)
return user
}
} catch (e) {
toast.error(`Fetching user failed: ${e.message}`)
}
return null
}, [serverURL, apiRoute, userSlug, i18n.language, setNewUser])
// On mount, get user and set
useEffect(() => {
void fetchFullUser()
}, [fetchFullUser])
// When location changes, refresh cookie
useEffect(() => {
if (id) {
refreshCookie()
}
}, [debouncedLocationChange, refreshCookie, id])
useEffect(() => {
setLastLocationChange(Date.now())
}, [pathname])
useEffect(() => {
let reminder: ReturnType<typeof setTimeout>
let forceLogOut: ReturnType<typeof setTimeout>
const now = Math.round(new Date().getTime() / 1000)
const remainingTime = typeof tokenExpiration === 'number' ? tokenExpiration - now : 0
const remindInTimeFromNow = Math.max(Math.min((remainingTime - 60) * 1000, maxTimeoutTime), 0)
const forceLogOutInTimeFromNow = Math.max(Math.min(remainingTime * 1000, maxTimeoutTime), 0)
if (!user) {
clearTimeout(reminder)
clearTimeout(forceLogOut)
return
}
if (remainingTime > 0) {
reminder = setTimeout(() => {
openModal(stayLoggedInModalSlug)
}, remindInTimeFromNow)
forceLogOut = setTimeout(() => {
setNewUser(null)
redirectToInactivityRoute()
}, forceLogOutInTimeFromNow)
}
return () => {
if (reminder) {
clearTimeout(reminder)
}
if (forceLogOut) {
clearTimeout(forceLogOut)
}
}
}, [tokenExpiration, openModal, i18n, setNewUser, user, redirectToInactivityRoute])
return (
<Context
value={{
fetchFullUser,
logOut,
permissions,
refreshCookie,
refreshCookieAsync,
refreshPermissions,
setPermissions,
setUser: setNewUser,
token: tokenInMemory,
user,
}}
>
{children}
</Context>
)
}
export const useAuth = <T = ClientUser,>(): AuthContext<T> => use(Context) as AuthContext<T>