chore!: improve auth provider setting user and user cookie (#8600)
### Improvements
- Uses overlay modal for "logging out..." display on logout view
- If user manually logs out it takes them directly to the login page
after logout, if caused by inactivity then they will see the logout page
that explains that they were logged out due to inactivity
- Fixes issue with cookie refresh triggering even after the user logs
out
- Cleans up auth provider timeouts for refresh and force logout
- `setUser` now expects the result similar to the response from the
`/me` endpoint, which includes the token, exp, and user
### BREAKING CHANGE
If you are using the `setUser` function exposed from the `useAuth()`
provider, then you will need to make some adjustments.
`setUser` now expects the response data from auth enabled endpoints, ie
the `/me` route. This is so the cookie and expiration can be properly
set in sync when a new user is set on the provider.
```ts
// before
setUser({
id: 670524817048be0fa222fc01,
email: dev@payloadcms.com,
// ... other user properties
})
// new
setUser({
user: {
id: 670524817048be0fa222fc01,
email: dev@payloadcms.com,
// ... other user properties
},
exp: 1728398351,
token: "....eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVC...."
})
```
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import type {
|
||||
ClientCollectionConfig,
|
||||
ClientUser,
|
||||
FormState,
|
||||
LoginWithUsernameOptions,
|
||||
MeOperationResult,
|
||||
} from 'payload'
|
||||
|
||||
import {
|
||||
@@ -57,8 +57,8 @@ export const CreateFirstUserClient: React.FC<{
|
||||
[apiRoute, userSlug, serverURL],
|
||||
)
|
||||
|
||||
const handleFirstRegister = (data: { user: ClientUser }) => {
|
||||
setUser(data.user)
|
||||
const handleFirstRegister = (data: MeOperationResult) => {
|
||||
setUser(data)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import React from 'react'
|
||||
const baseClass = 'login__form'
|
||||
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
|
||||
|
||||
import type { ClientUser, FormState } from 'payload'
|
||||
import type { ClientUser, FormState, MeOperationResult } from 'payload'
|
||||
|
||||
import { Form, FormSubmit, PasswordField, useAuth, useConfig, useTranslation } from '@payloadcms/ui'
|
||||
import { formatAdminURL } from '@payloadcms/ui/shared'
|
||||
@@ -74,8 +74,8 @@ export const LoginForm: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = (data: { user: ClientUser }) => {
|
||||
setUser(data.user)
|
||||
const handleLogin = (data: MeOperationResult) => {
|
||||
setUser(data)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
import { Button, useAuth, useTranslation } from '@payloadcms/ui'
|
||||
import { Button, LoadingOverlay, toast, useAuth, useTranslation } from '@payloadcms/ui'
|
||||
import { formatAdminURL } from '@payloadcms/ui/shared'
|
||||
import LinkImport from 'next/link.js'
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation.js'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
@@ -17,39 +18,49 @@ export const LogoutClient: React.FC<{
|
||||
}> = (props) => {
|
||||
const { adminRoute, inactivity, redirect } = props
|
||||
|
||||
const [isLoggingOut, setIsLoggingOut] = React.useState<boolean | undefined>(undefined)
|
||||
const { logOut } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggingOut) {
|
||||
setIsLoggingOut(true)
|
||||
void logOut()
|
||||
}
|
||||
}, [isLoggingOut, logOut])
|
||||
|
||||
if (isLoggingOut) {
|
||||
return (
|
||||
<div className={`${baseClass}__wrap`}>
|
||||
{inactivity && <h2>{t('authentication:loggedOutInactivity')}</h2>}
|
||||
{!inactivity && <h2>{t('authentication:loggedOutSuccessfully')}</h2>}
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
el="link"
|
||||
Link={Link}
|
||||
size="large"
|
||||
url={formatAdminURL({
|
||||
const [isLoggedOut, setIsLoggedOut] = React.useState<boolean | undefined>(undefined)
|
||||
const logOutSuccessRef = React.useRef(false)
|
||||
const [loginRoute] = React.useState(() =>
|
||||
formatAdminURL({
|
||||
adminRoute,
|
||||
path: `/login${
|
||||
redirect && redirect.length > 0 ? `?redirect=${encodeURIComponent(redirect)}` : ''
|
||||
inactivity && redirect && redirect.length > 0
|
||||
? `?redirect=${encodeURIComponent(redirect)}`
|
||||
: ''
|
||||
}`,
|
||||
})}
|
||||
>
|
||||
}),
|
||||
)
|
||||
const { logOut } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogOut = React.useCallback(async () => {
|
||||
const loggedOut = await logOut()
|
||||
setIsLoggedOut(loggedOut)
|
||||
if (!inactivity && loggedOut && !logOutSuccessRef.current) {
|
||||
toast.success(t('authentication:loggedOutSuccessfully'))
|
||||
logOutSuccessRef.current = true
|
||||
router.push(loginRoute)
|
||||
return
|
||||
}
|
||||
}, [inactivity, logOut, loginRoute, router, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedOut) {
|
||||
void handleLogOut()
|
||||
}
|
||||
}, [handleLogOut, isLoggedOut])
|
||||
|
||||
if (isLoggedOut && inactivity) {
|
||||
return (
|
||||
<div className={`${baseClass}__wrap`}>
|
||||
<h2>{t('authentication:loggedOutInactivity')}</h2>
|
||||
<Button buttonStyle="secondary" el="link" Link={Link} size="large" url={loginRoute}>
|
||||
{t('authentication:logBackIn')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Fragment>{t('authentication:loggingOut')}</Fragment>
|
||||
return <LoadingOverlay animationDuration={'0ms'} loadingText={t('authentication:loggingOut')} />
|
||||
}
|
||||
|
||||
@@ -14,19 +14,21 @@ import { requests } from '../../utilities/api.js'
|
||||
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
|
||||
import { useConfig } from '../Config/index.js'
|
||||
|
||||
export type AuthContext<T = ClientUser> = {
|
||||
export type UserResponse = MeOperationResult | null
|
||||
|
||||
export type AuthContext = {
|
||||
fetchFullUser: () => Promise<null | User>
|
||||
logOut: () => Promise<void>
|
||||
logOut: () => Promise<boolean>
|
||||
permissions?: Permissions
|
||||
refreshCookie: (forceRefresh?: boolean) => void
|
||||
refreshCookieAsync: () => Promise<ClientUser>
|
||||
refreshPermissions: () => Promise<void>
|
||||
setPermissions: (permissions: Permissions) => void
|
||||
setUser: (user: T) => void
|
||||
setUser: (user: UserResponse) => void
|
||||
strategy?: string
|
||||
token?: string
|
||||
tokenExpiration?: number
|
||||
user?: null | T
|
||||
user?: null | UserResponse['user']
|
||||
}
|
||||
|
||||
const Context = createContext({} as AuthContext)
|
||||
@@ -43,7 +45,7 @@ export function AuthProvider({
|
||||
permissions: initialPermissions,
|
||||
user: initialUser,
|
||||
}: Props) {
|
||||
const [user, setUser] = useState<ClientUser | null>(initialUser)
|
||||
const [user, setUserInMemory] = useState<ClientUser | null>(initialUser)
|
||||
const [tokenInMemory, setTokenInMemory] = useState<string>()
|
||||
const [tokenExpiration, setTokenExpiration] = useState<number>()
|
||||
const pathname = usePathname()
|
||||
@@ -66,6 +68,7 @@ export function AuthProvider({
|
||||
const { closeAllModals, openModal } = useModal()
|
||||
const [lastLocationChange, setLastLocationChange] = useState(0)
|
||||
const debouncedLocationChange = useDebounce(lastLocationChange, 10000)
|
||||
const refreshTokenTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const id = user?.id
|
||||
|
||||
@@ -92,15 +95,17 @@ export function AuthProvider({
|
||||
const revokeTokenAndExpire = useCallback(() => {
|
||||
setTokenInMemory(undefined)
|
||||
setTokenExpiration(undefined)
|
||||
clearTimeout(refreshTokenTimeoutRef.current)
|
||||
}, [])
|
||||
|
||||
const setTokenAndExpiration = useCallback(
|
||||
(json) => {
|
||||
const token = json?.token || json?.refreshedToken
|
||||
if (token && json?.exp) {
|
||||
setTokenInMemory(token)
|
||||
setTokenExpiration(json.exp)
|
||||
const setNewUser = useCallback(
|
||||
(userResponse: UserResponse) => {
|
||||
if (userResponse?.user) {
|
||||
setUserInMemory(userResponse.user)
|
||||
setTokenInMemory(userResponse.token)
|
||||
setTokenExpiration(userResponse.exp)
|
||||
} else {
|
||||
setUserInMemory(null)
|
||||
revokeTokenAndExpire()
|
||||
}
|
||||
},
|
||||
@@ -113,11 +118,11 @@ export function AuthProvider({
|
||||
const remainingTime = (typeof tokenExpiration === 'number' ? tokenExpiration : 0) - now
|
||||
|
||||
if (forceRefresh || (tokenExpiration && remainingTime < 120)) {
|
||||
setTimeout(() => {
|
||||
refreshTokenTimeoutRef.current = setTimeout(() => {
|
||||
async function refresh() {
|
||||
try {
|
||||
const request = await requests.post(
|
||||
`${serverURL}${apiRoute}/${userSlug}/refresh-token`,
|
||||
`${serverURL}${apiRoute}/${userSlug}/refresh-token?refresh`,
|
||||
{
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
@@ -127,11 +132,9 @@ export function AuthProvider({
|
||||
|
||||
if (request.status === 200) {
|
||||
const json = await request.json()
|
||||
setUser(json.user)
|
||||
|
||||
setTokenAndExpiration(json)
|
||||
setNewUser(json)
|
||||
} else {
|
||||
setUser(null)
|
||||
setNewUser(null)
|
||||
redirectToInactivityRoute()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -142,15 +145,19 @@ export function AuthProvider({
|
||||
void refresh()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(refreshTokenTimeoutRef.current)
|
||||
}
|
||||
},
|
||||
[
|
||||
tokenExpiration,
|
||||
serverURL,
|
||||
apiRoute,
|
||||
userSlug,
|
||||
i18n,
|
||||
i18n.language,
|
||||
redirectToInactivityRoute,
|
||||
setTokenAndExpiration,
|
||||
serverURL,
|
||||
setNewUser,
|
||||
tokenExpiration,
|
||||
userSlug,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -166,13 +173,12 @@ export function AuthProvider({
|
||||
if (request.status === 200) {
|
||||
const json = await request.json()
|
||||
if (!skipSetUser) {
|
||||
setUser(json.user)
|
||||
setTokenAndExpiration(json)
|
||||
setNewUser(json)
|
||||
}
|
||||
return json.user
|
||||
}
|
||||
|
||||
setUser(null)
|
||||
setNewUser(null)
|
||||
redirectToInactivityRoute()
|
||||
return null
|
||||
} catch (e) {
|
||||
@@ -180,18 +186,20 @@ export function AuthProvider({
|
||||
return null
|
||||
}
|
||||
},
|
||||
[serverURL, apiRoute, userSlug, i18n, redirectToInactivityRoute, setTokenAndExpiration],
|
||||
[apiRoute, i18n.language, redirectToInactivityRoute, serverURL, setNewUser, userSlug],
|
||||
)
|
||||
|
||||
const logOut = useCallback(async () => {
|
||||
setUser(null)
|
||||
revokeTokenAndExpire()
|
||||
try {
|
||||
await requests.post(`${serverURL}${apiRoute}/${userSlug}/logout`)
|
||||
setNewUser(null)
|
||||
revokeTokenAndExpire()
|
||||
return true
|
||||
} catch (e) {
|
||||
toast.error(`Logging out failed: ${e.message}`)
|
||||
return false
|
||||
}
|
||||
}, [serverURL, apiRoute, userSlug, revokeTokenAndExpire])
|
||||
}, [apiRoute, revokeTokenAndExpire, serverURL, setNewUser, userSlug])
|
||||
|
||||
const refreshPermissions = useCallback(
|
||||
async ({ locale }: { locale?: string } = {}) => {
|
||||
@@ -235,20 +243,9 @@ export function AuthProvider({
|
||||
|
||||
if (request.status === 200) {
|
||||
const json: MeOperationResult = await request.json()
|
||||
let user = null
|
||||
|
||||
if (json?.user) {
|
||||
setUser(json.user)
|
||||
user = json.user
|
||||
|
||||
if (json?.token) {
|
||||
setTokenAndExpiration(json)
|
||||
}
|
||||
} else {
|
||||
setUser(null)
|
||||
revokeTokenAndExpire()
|
||||
}
|
||||
const user = null
|
||||
|
||||
setNewUser(json)
|
||||
return user
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -256,7 +253,7 @@ export function AuthProvider({
|
||||
}
|
||||
|
||||
return null
|
||||
}, [serverURL, apiRoute, userSlug, i18n.language, setTokenAndExpiration, revokeTokenAndExpire])
|
||||
}, [serverURL, apiRoute, userSlug, i18n.language, setNewUser])
|
||||
|
||||
// On mount, get user and set
|
||||
useEffect(() => {
|
||||
@@ -270,58 +267,43 @@ export function AuthProvider({
|
||||
}
|
||||
}, [debouncedLocationChange, refreshCookie, id])
|
||||
|
||||
// When initialUser changes, reset in state
|
||||
useEffect(() => {
|
||||
setUser(initialUser)
|
||||
}, [initialUser])
|
||||
|
||||
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(
|
||||
() => {
|
||||
reminder = setTimeout(() => {
|
||||
openModal(stayLoggedInModalSlug)
|
||||
},
|
||||
Math.max(Math.min((remainingTime - 60) * 1000, maxTimeoutTime)),
|
||||
)
|
||||
}, remindInTimeFromNow)
|
||||
|
||||
forceLogOut = setTimeout(() => {
|
||||
setNewUser(null)
|
||||
}, forceLogOutInTimeFromNow)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reminder) {
|
||||
clearTimeout(reminder)
|
||||
}
|
||||
}
|
||||
}, [tokenExpiration, openModal])
|
||||
|
||||
useEffect(() => {
|
||||
let forceLogOut: ReturnType<typeof setTimeout>
|
||||
const now = Math.round(new Date().getTime() / 1000)
|
||||
const remainingTime = typeof tokenExpiration === 'number' ? tokenExpiration - now : 0
|
||||
|
||||
if (remainingTime > 0) {
|
||||
forceLogOut = setTimeout(
|
||||
() => {
|
||||
setUser(null)
|
||||
revokeTokenAndExpire()
|
||||
redirectToInactivityRoute()
|
||||
},
|
||||
Math.max(Math.min(remainingTime * 1000, maxTimeoutTime), 0),
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (forceLogOut) {
|
||||
clearTimeout(forceLogOut)
|
||||
}
|
||||
}
|
||||
}, [tokenExpiration, closeAllModals, i18n, redirectToInactivityRoute, revokeTokenAndExpire])
|
||||
}, [tokenExpiration, openModal, i18n, setNewUser, user])
|
||||
|
||||
return (
|
||||
<Context.Provider
|
||||
@@ -333,7 +315,7 @@ export function AuthProvider({
|
||||
refreshCookieAsync,
|
||||
refreshPermissions,
|
||||
setPermissions,
|
||||
setUser,
|
||||
setUser: setNewUser,
|
||||
token: tokenInMemory,
|
||||
user,
|
||||
}}
|
||||
@@ -343,4 +325,4 @@ export function AuthProvider({
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = <T = ClientUser,>(): AuthContext<T> => useContext(Context) as AuthContext<T>
|
||||
export const useAuth = (): AuthContext => useContext(Context)
|
||||
|
||||
Reference in New Issue
Block a user