feat(ui): adds admin.autoRefresh root config property (#13682)

Adds the `admin.autoRefresh` property to the root config. This allows
users to stay logged and have their token always refresh in the
background without being prompted with the "Stay Logged In?" modal.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211114366468735
This commit is contained in:
Jarrod Flesch
2025-09-05 17:05:18 -04:00
committed by GitHub
parent 03f7102433
commit f288cf6a8f
11 changed files with 150 additions and 119 deletions

View File

@@ -98,6 +98,7 @@ The following options are available:
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
| `autoLogin` | Used to automate log-in for dev and demonstration convenience. [More details](../authentication/overview). |
| `autoRefresh` | Used to automatically refresh user tokens for users logged into the dashboard. [More details](../authentication/overview). |
| `components` | Component overrides that affect the entirety of the Admin Panel. [More details](../custom-components/overview). |
| `custom` | Any custom properties you wish to pass to the Admin Panel. |
| `dateFormat` | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |

View File

@@ -173,6 +173,25 @@ The following options are available:
| **`password`** | The password of the user to login as. This is only needed if `prefillOnly` is set to true |
| **`prefillOnly`** | If set to true, the login credentials will be prefilled but the user will still need to click the login button. |
## Auto-Refresh
Turning this property on will allow users to stay logged in indefinitely while their browser is open and on the admin panel, by automatically refreshing their authentication token before it expires.
To enable auto-refresh for user tokens, set `autoRefresh: true` in the [Payload Config](../admin/overview#admin-options) to:
```ts
import { buildConfig } from 'payload'
export default buildConfig({
// ...
// highlight-start
admin: {
autoRefresh: true,
},
// highlight-end
})
```
## Operations
All auth-related operations are available via Payload's REST, Local, and GraphQL APIs. These operations are automatically added to your Collection when you enable Authentication. [More details](./operations).

View File

@@ -17,6 +17,11 @@ export async function refresh({ config }: { config: any }) {
throw new Error('Cannot refresh token: user not authenticated')
}
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
if (!existingCookie) {
return { message: 'No valid token found to refresh', success: false }
}
const collection: CollectionSlug | undefined = result.user.collection
const collectionConfig = payload.collections[collection]
@@ -35,15 +40,10 @@ export async function refresh({ config }: { config: any }) {
return { message: 'Token refresh failed', success: false }
}
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
if (!existingCookie) {
return { message: 'No valid token found to refresh', success: false }
}
await setPayloadAuthCookie({
authConfig: collectionConfig.config.auth,
cookiePrefix: payload.config.cookiePrefix,
token: existingCookie.value,
token: refreshResult.refreshedToken,
})
return { message: 'Token refreshed successfully', success: true }

View File

@@ -760,6 +760,12 @@ export type Config = {
username?: string
}
| false
/**
* Automatically refresh user tokens for users logged into the dashboard
*
* @default false
*/
autoRefresh?: boolean
/** Set account profile picture. Options: gravatar, default or a custom React component. */
avatar?:
| 'default'

View File

@@ -198,7 +198,8 @@ export const Status: React.FC = () => {
/>
</React.Fragment>
)}
{!isTrashed && canUpdate && statusToRender === 'changed' || statusToRender === 'draft' && (
{((!isTrashed && canUpdate && statusToRender === 'changed') ||
statusToRender === 'draft') && (
<React.Fragment>
&nbsp;&mdash;&nbsp;
<Button

View File

@@ -27,7 +27,9 @@ export const RowLabelProvider: React.FC<Props<unknown>> = ({ children, path, row
const data = arrayData || collapsibleData
return <RowLabel value={{ data, path, rowNumber }}>{children}</RowLabel>
const contextValue = React.useMemo(() => ({ data, path, rowNumber }), [data, path, rowNumber])
return <RowLabel value={contextValue}>{children}</RowLabel>
}
export const useRowLabel = <T,>() => {

View File

@@ -9,7 +9,6 @@ import React, { createContext, use, useCallback, useEffect, useState } from 'rea
import { toast } from 'sonner'
import { stayLoggedInModalSlug } from '../../elements/StayLoggedIn/index.js'
import { useDebounce } from '../../hooks/useDebounce.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { requests } from '../../utilities/api.js'
@@ -17,6 +16,7 @@ import { useConfig } from '../Config/index.js'
import { useRouteTransition } from '../RouteTransition/index.js'
export type UserWithToken<T = ClientUser> = {
/** seconds until expiration */
exp: number
token: string
user: T
@@ -33,13 +33,13 @@ export type AuthContext<T = ClientUser> = {
setUser: (user: null | UserWithToken<T>) => void
strategy?: string
token?: string
tokenExpiration?: number
tokenExpirationMs?: number
user?: null | T
}
const Context = createContext({} as AuthContext)
const maxTimeoutTime = 2147483647
const maxTimeoutMs = 2147483647
type Props = {
children: React.ReactNode
@@ -52,9 +52,6 @@ export function AuthProvider({
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()
@@ -62,6 +59,7 @@ export function AuthProvider({
const {
admin: {
autoRefresh,
routes: { inactivity: logoutInactivityRoute },
user: userSlug,
},
@@ -69,15 +67,21 @@ export function AuthProvider({
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 [user, setUserInMemory] = useState<ClientUser | null>(initialUser)
const [tokenInMemory, setTokenInMemory] = useState<string>()
const [tokenExpirationMs, setTokenExpirationMs] = useState<number>()
const [permissions, setPermissions] = useState<SanitizedPermissions>(initialPermissions)
const [forceLogoutBufferMs, setForceLogoutBufferMs] = useState<number>(120_000)
const [fetchedUserOnMount, setFetchedUserOnMount] = useState(false)
const refreshTokenTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(null)
const reminderTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(null)
const forceLogOutTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(null)
const id = user?.id
const redirectToInactivityRoute = useCallback(() => {
@@ -94,61 +98,86 @@ export function AuthProvider({
}, [router, adminRoute, logoutInactivityRoute, closeAllModals, startRouteTransition])
const revokeTokenAndExpire = useCallback(() => {
setUserInMemory(null)
setTokenInMemory(undefined)
setTokenExpiration(undefined)
setTokenExpirationMs(undefined)
clearTimeout(refreshTokenTimeoutRef.current)
}, [])
const setNewUser = useCallback(
(userResponse: null | UserWithToken) => {
clearTimeout(reminderTimeoutRef.current)
clearTimeout(forceLogOutTimeoutRef.current)
if (userResponse?.user) {
setUserInMemory(userResponse.user)
setTokenInMemory(userResponse.token)
setTokenExpiration(userResponse.exp)
setTokenExpirationMs(userResponse.exp * 1000)
const expiresInMs = Math.max(
0,
Math.min((userResponse.exp ?? 0) * 1000 - Date.now(), maxTimeoutMs),
)
if (expiresInMs) {
const nextForceLogoutBufferMs = Math.min(60_000, expiresInMs / 2)
setForceLogoutBufferMs(nextForceLogoutBufferMs)
reminderTimeoutRef.current = setTimeout(
() => {
if (autoRefresh) {
refreshCookieEvent()
} else {
openModal(stayLoggedInModalSlug)
}
},
Math.max(expiresInMs - nextForceLogoutBufferMs, 0),
)
forceLogOutTimeoutRef.current = setTimeout(() => {
revokeTokenAndExpire()
redirectToInactivityRoute()
}, expiresInMs)
}
} else {
setUserInMemory(null)
revokeTokenAndExpire()
}
},
[revokeTokenAndExpire],
[autoRefresh, redirectToInactivityRoute, revokeTokenAndExpire, openModal],
)
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)
if (!id) {
return
}
return () => {
const expiresInMs = Math.max(0, (tokenExpirationMs ?? 0) - Date.now())
if (forceRefresh || (tokenExpirationMs && expiresInMs < forceLogoutBufferMs * 2)) {
clearTimeout(refreshTokenTimeoutRef.current)
refreshTokenTimeoutRef.current = setTimeout(async () => {
try {
const request = await requests.post(
`${serverURL}${apiRoute}/${userSlug}/refresh-token?refresh`,
{
headers: {
'Accept-Language': i18n.language,
},
},
)
if (request.status === 200) {
const json: UserWithToken = await request.json()
setNewUser(json)
} else {
setNewUser(null)
redirectToInactivityRoute()
}
} catch (e) {
toast.error(e.message)
}
}, 1000)
}
},
[
@@ -157,8 +186,10 @@ export function AuthProvider({
redirectToInactivityRoute,
serverURL,
setNewUser,
tokenExpiration,
tokenExpirationMs,
userSlug,
forceLogoutBufferMs,
id,
],
)
@@ -172,7 +203,7 @@ export function AuthProvider({
})
if (request.status === 200) {
const json = await request.json()
const json: UserWithToken = await request.json()
if (!skipSetUser) {
setNewUser(json)
}
@@ -183,11 +214,10 @@ export function AuthProvider({
setNewUser(null)
redirectToInactivityRoute()
}
return null
} catch (e) {
toast.error(`Refreshing token failed: ${e.message}`)
return null
}
return null
},
[apiRoute, i18n.language, redirectToInactivityRoute, serverURL, setNewUser, userSlug, user],
)
@@ -247,10 +277,8 @@ export function AuthProvider({
if (request.status === 200) {
const json: UserWithToken = await request.json()
const user = null
setNewUser(json)
return user
return json?.user || null
}
} catch (e) {
toast.error(`Fetching user failed: ${e.message}`)
@@ -259,62 +287,35 @@ export function AuthProvider({
return null
}, [serverURL, apiRoute, userSlug, i18n.language, setNewUser])
const fetchFullUserEvent = useEffectEvent(fetchFullUser)
// On mount, get user and set
useEffect(() => {
void fetchFullUserEvent()
}, [])
const refreshCookieEvent = useEffectEvent(() => {
if (id) {
refreshCookie()
}
})
// When location changes, refresh cookie
const refreshCookieEvent = useEffectEvent(refreshCookie)
useEffect(() => {
// when location changes, refresh cookie
refreshCookieEvent()
}, [debouncedLocationChange])
useEffect(() => {
setLastLocationChange(Date.now())
}, [pathname])
const fetchFullUserEvent = useEffectEvent(fetchFullUser)
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
async function fetchUserOnMount() {
await fetchFullUserEvent()
setFetchedUserOnMount(true)
}
if (remainingTime > 0) {
reminder = setTimeout(() => {
openModal(stayLoggedInModalSlug)
}, remindInTimeFromNow)
void fetchUserOnMount()
}, [])
forceLogOut = setTimeout(() => {
setNewUser(null)
redirectToInactivityRoute()
}, forceLogOutInTimeFromNow)
}
useEffect(
() => () => {
// remove all timeouts on unmount
clearTimeout(refreshTokenTimeoutRef.current)
clearTimeout(reminderTimeoutRef.current)
clearTimeout(forceLogOutTimeoutRef.current)
},
[],
)
return () => {
if (reminder) {
clearTimeout(reminder)
}
if (forceLogOut) {
clearTimeout(forceLogOut)
}
}
}, [tokenExpiration, openModal, i18n, setNewUser, user, redirectToInactivityRoute])
if (!user && !fetchedUserOnMount) {
return null
}
return (
<Context
@@ -328,6 +329,7 @@ export function AuthProvider({
setPermissions,
setUser: setNewUser,
token: tokenInMemory,
tokenExpirationMs,
user,
}}
>

View File

@@ -3,7 +3,7 @@ import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from "next/server"
import { NextRequest } from 'next/server'
import configPromise from '@payload-config'

View File

@@ -3,7 +3,7 @@ import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from "next/server"
import { NextRequest } from 'next/server'
import configPromise from '@payload-config'

View File

@@ -102,16 +102,16 @@ describe('Array', () => {
})
.catch(() => false) // If it doesn't appear, this resolves to `false`
expect(defaultRowLabelWasAttached).toBeFalsy()
await expect.poll(() => defaultRowLabelWasAttached).toBeFalsy()
await expect(page.locator('#field-rowLabelAsComponent #custom-array-row-label')).toBeVisible()
await page.locator('#field-rowLabelAsComponent__0__title').fill(label)
await wait(100)
const customRowLabel = page.locator(
'#rowLabelAsComponent-row-0 >> .array-field__row-header > :text("custom row label")',
)
await expect(customRowLabel).toBeVisible()
await expect(customRowLabel).toHaveCSS('text-transform', 'uppercase')
})

View File

@@ -90,17 +90,17 @@ describe('Collapsibles', () => {
await addArrayRow(page, { fieldName: 'arrayWithCollapsibles' })
await page
.locator(
'#arrayWithCollapsibles-row-0 #field-collapsible-arrayWithCollapsibles__0___index-0 #field-arrayWithCollapsibles__0__innerCollapsible',
)
.fill(label)
await wait(100)
const innerTextField = page.locator(
'#arrayWithCollapsibles-row-0 #field-collapsible-arrayWithCollapsibles__0___index-0 #field-arrayWithCollapsibles__0__innerCollapsible',
)
await expect(innerTextField).toBeVisible()
await innerTextField.fill(label)
const customCollapsibleLabel = page.locator(
`#field-arrayWithCollapsibles >> #arrayWithCollapsibles-row-0 >> .collapsible-field__row-label-wrap :text("${label}")`,
)
await expect(customCollapsibleLabel).toBeVisible()
await expect(customCollapsibleLabel).toHaveCSS('text-transform', 'uppercase')
})
})