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:
@@ -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. |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
—
|
||||
<Button
|
||||
|
||||
@@ -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,>() => {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user