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'
|
'use client'
|
||||||
import type {
|
import type {
|
||||||
ClientCollectionConfig,
|
ClientCollectionConfig,
|
||||||
ClientUser,
|
|
||||||
FormState,
|
FormState,
|
||||||
LoginWithUsernameOptions,
|
LoginWithUsernameOptions,
|
||||||
|
MeOperationResult,
|
||||||
} from 'payload'
|
} from 'payload'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -57,8 +57,8 @@ export const CreateFirstUserClient: React.FC<{
|
|||||||
[apiRoute, userSlug, serverURL],
|
[apiRoute, userSlug, serverURL],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleFirstRegister = (data: { user: ClientUser }) => {
|
const handleFirstRegister = (data: MeOperationResult) => {
|
||||||
setUser(data.user)
|
setUser(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import React from 'react'
|
|||||||
const baseClass = 'login__form'
|
const baseClass = 'login__form'
|
||||||
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
|
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 { Form, FormSubmit, PasswordField, useAuth, useConfig, useTranslation } from '@payloadcms/ui'
|
||||||
import { formatAdminURL } from '@payloadcms/ui/shared'
|
import { formatAdminURL } from '@payloadcms/ui/shared'
|
||||||
@@ -74,8 +74,8 @@ export const LoginForm: React.FC<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = (data: { user: ClientUser }) => {
|
const handleLogin = (data: MeOperationResult) => {
|
||||||
setUser(data.user)
|
setUser(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'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 { formatAdminURL } from '@payloadcms/ui/shared'
|
||||||
import LinkImport from 'next/link.js'
|
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'
|
import './index.scss'
|
||||||
|
|
||||||
@@ -17,39 +18,49 @@ export const LogoutClient: React.FC<{
|
|||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const { adminRoute, inactivity, redirect } = props
|
const { adminRoute, inactivity, redirect } = props
|
||||||
|
|
||||||
const [isLoggingOut, setIsLoggingOut] = React.useState<boolean | undefined>(undefined)
|
const [isLoggedOut, setIsLoggedOut] = React.useState<boolean | undefined>(undefined)
|
||||||
|
const logOutSuccessRef = React.useRef(false)
|
||||||
|
const [loginRoute] = React.useState(() =>
|
||||||
|
formatAdminURL({
|
||||||
|
adminRoute,
|
||||||
|
path: `/login${
|
||||||
|
inactivity && redirect && redirect.length > 0
|
||||||
|
? `?redirect=${encodeURIComponent(redirect)}`
|
||||||
|
: ''
|
||||||
|
}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
const { logOut } = useAuth()
|
const { logOut } = useAuth()
|
||||||
const { t } = useTranslation()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLoggingOut) {
|
if (!isLoggedOut) {
|
||||||
setIsLoggingOut(true)
|
void handleLogOut()
|
||||||
void logOut()
|
|
||||||
}
|
}
|
||||||
}, [isLoggingOut, logOut])
|
}, [handleLogOut, isLoggedOut])
|
||||||
|
|
||||||
if (isLoggingOut) {
|
if (isLoggedOut && inactivity) {
|
||||||
return (
|
return (
|
||||||
<div className={`${baseClass}__wrap`}>
|
<div className={`${baseClass}__wrap`}>
|
||||||
{inactivity && <h2>{t('authentication:loggedOutInactivity')}</h2>}
|
<h2>{t('authentication:loggedOutInactivity')}</h2>
|
||||||
{!inactivity && <h2>{t('authentication:loggedOutSuccessfully')}</h2>}
|
<Button buttonStyle="secondary" el="link" Link={Link} size="large" url={loginRoute}>
|
||||||
<Button
|
|
||||||
buttonStyle="secondary"
|
|
||||||
el="link"
|
|
||||||
Link={Link}
|
|
||||||
size="large"
|
|
||||||
url={formatAdminURL({
|
|
||||||
adminRoute,
|
|
||||||
path: `/login${
|
|
||||||
redirect && redirect.length > 0 ? `?redirect=${encodeURIComponent(redirect)}` : ''
|
|
||||||
}`,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('authentication:logBackIn')}
|
{t('authentication:logBackIn')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 { formatAdminURL } from '../../utilities/formatAdminURL.js'
|
||||||
import { useConfig } from '../Config/index.js'
|
import { useConfig } from '../Config/index.js'
|
||||||
|
|
||||||
export type AuthContext<T = ClientUser> = {
|
export type UserResponse = MeOperationResult | null
|
||||||
|
|
||||||
|
export type AuthContext = {
|
||||||
fetchFullUser: () => Promise<null | User>
|
fetchFullUser: () => Promise<null | User>
|
||||||
logOut: () => Promise<void>
|
logOut: () => Promise<boolean>
|
||||||
permissions?: Permissions
|
permissions?: Permissions
|
||||||
refreshCookie: (forceRefresh?: boolean) => void
|
refreshCookie: (forceRefresh?: boolean) => void
|
||||||
refreshCookieAsync: () => Promise<ClientUser>
|
refreshCookieAsync: () => Promise<ClientUser>
|
||||||
refreshPermissions: () => Promise<void>
|
refreshPermissions: () => Promise<void>
|
||||||
setPermissions: (permissions: Permissions) => void
|
setPermissions: (permissions: Permissions) => void
|
||||||
setUser: (user: T) => void
|
setUser: (user: UserResponse) => void
|
||||||
strategy?: string
|
strategy?: string
|
||||||
token?: string
|
token?: string
|
||||||
tokenExpiration?: number
|
tokenExpiration?: number
|
||||||
user?: null | T
|
user?: null | UserResponse['user']
|
||||||
}
|
}
|
||||||
|
|
||||||
const Context = createContext({} as AuthContext)
|
const Context = createContext({} as AuthContext)
|
||||||
@@ -43,7 +45,7 @@ export function AuthProvider({
|
|||||||
permissions: initialPermissions,
|
permissions: initialPermissions,
|
||||||
user: initialUser,
|
user: initialUser,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [user, setUser] = useState<ClientUser | null>(initialUser)
|
const [user, setUserInMemory] = useState<ClientUser | null>(initialUser)
|
||||||
const [tokenInMemory, setTokenInMemory] = useState<string>()
|
const [tokenInMemory, setTokenInMemory] = useState<string>()
|
||||||
const [tokenExpiration, setTokenExpiration] = useState<number>()
|
const [tokenExpiration, setTokenExpiration] = useState<number>()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -66,6 +68,7 @@ export function AuthProvider({
|
|||||||
const { closeAllModals, openModal } = useModal()
|
const { closeAllModals, openModal } = useModal()
|
||||||
const [lastLocationChange, setLastLocationChange] = useState(0)
|
const [lastLocationChange, setLastLocationChange] = useState(0)
|
||||||
const debouncedLocationChange = useDebounce(lastLocationChange, 10000)
|
const debouncedLocationChange = useDebounce(lastLocationChange, 10000)
|
||||||
|
const refreshTokenTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>(null)
|
||||||
|
|
||||||
const id = user?.id
|
const id = user?.id
|
||||||
|
|
||||||
@@ -92,15 +95,17 @@ export function AuthProvider({
|
|||||||
const revokeTokenAndExpire = useCallback(() => {
|
const revokeTokenAndExpire = useCallback(() => {
|
||||||
setTokenInMemory(undefined)
|
setTokenInMemory(undefined)
|
||||||
setTokenExpiration(undefined)
|
setTokenExpiration(undefined)
|
||||||
|
clearTimeout(refreshTokenTimeoutRef.current)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const setTokenAndExpiration = useCallback(
|
const setNewUser = useCallback(
|
||||||
(json) => {
|
(userResponse: UserResponse) => {
|
||||||
const token = json?.token || json?.refreshedToken
|
if (userResponse?.user) {
|
||||||
if (token && json?.exp) {
|
setUserInMemory(userResponse.user)
|
||||||
setTokenInMemory(token)
|
setTokenInMemory(userResponse.token)
|
||||||
setTokenExpiration(json.exp)
|
setTokenExpiration(userResponse.exp)
|
||||||
} else {
|
} else {
|
||||||
|
setUserInMemory(null)
|
||||||
revokeTokenAndExpire()
|
revokeTokenAndExpire()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -113,11 +118,11 @@ export function AuthProvider({
|
|||||||
const remainingTime = (typeof tokenExpiration === 'number' ? tokenExpiration : 0) - now
|
const remainingTime = (typeof tokenExpiration === 'number' ? tokenExpiration : 0) - now
|
||||||
|
|
||||||
if (forceRefresh || (tokenExpiration && remainingTime < 120)) {
|
if (forceRefresh || (tokenExpiration && remainingTime < 120)) {
|
||||||
setTimeout(() => {
|
refreshTokenTimeoutRef.current = setTimeout(() => {
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const request = await requests.post(
|
const request = await requests.post(
|
||||||
`${serverURL}${apiRoute}/${userSlug}/refresh-token`,
|
`${serverURL}${apiRoute}/${userSlug}/refresh-token?refresh`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Accept-Language': i18n.language,
|
'Accept-Language': i18n.language,
|
||||||
@@ -127,11 +132,9 @@ export function AuthProvider({
|
|||||||
|
|
||||||
if (request.status === 200) {
|
if (request.status === 200) {
|
||||||
const json = await request.json()
|
const json = await request.json()
|
||||||
setUser(json.user)
|
setNewUser(json)
|
||||||
|
|
||||||
setTokenAndExpiration(json)
|
|
||||||
} else {
|
} else {
|
||||||
setUser(null)
|
setNewUser(null)
|
||||||
redirectToInactivityRoute()
|
redirectToInactivityRoute()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -142,15 +145,19 @@ export function AuthProvider({
|
|||||||
void refresh()
|
void refresh()
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(refreshTokenTimeoutRef.current)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
tokenExpiration,
|
|
||||||
serverURL,
|
|
||||||
apiRoute,
|
apiRoute,
|
||||||
userSlug,
|
i18n.language,
|
||||||
i18n,
|
|
||||||
redirectToInactivityRoute,
|
redirectToInactivityRoute,
|
||||||
setTokenAndExpiration,
|
serverURL,
|
||||||
|
setNewUser,
|
||||||
|
tokenExpiration,
|
||||||
|
userSlug,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -166,13 +173,12 @@ export function AuthProvider({
|
|||||||
if (request.status === 200) {
|
if (request.status === 200) {
|
||||||
const json = await request.json()
|
const json = await request.json()
|
||||||
if (!skipSetUser) {
|
if (!skipSetUser) {
|
||||||
setUser(json.user)
|
setNewUser(json)
|
||||||
setTokenAndExpiration(json)
|
|
||||||
}
|
}
|
||||||
return json.user
|
return json.user
|
||||||
}
|
}
|
||||||
|
|
||||||
setUser(null)
|
setNewUser(null)
|
||||||
redirectToInactivityRoute()
|
redirectToInactivityRoute()
|
||||||
return null
|
return null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -180,18 +186,20 @@ export function AuthProvider({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[serverURL, apiRoute, userSlug, i18n, redirectToInactivityRoute, setTokenAndExpiration],
|
[apiRoute, i18n.language, redirectToInactivityRoute, serverURL, setNewUser, userSlug],
|
||||||
)
|
)
|
||||||
|
|
||||||
const logOut = useCallback(async () => {
|
const logOut = useCallback(async () => {
|
||||||
setUser(null)
|
|
||||||
revokeTokenAndExpire()
|
|
||||||
try {
|
try {
|
||||||
await requests.post(`${serverURL}${apiRoute}/${userSlug}/logout`)
|
await requests.post(`${serverURL}${apiRoute}/${userSlug}/logout`)
|
||||||
|
setNewUser(null)
|
||||||
|
revokeTokenAndExpire()
|
||||||
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(`Logging out failed: ${e.message}`)
|
toast.error(`Logging out failed: ${e.message}`)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}, [serverURL, apiRoute, userSlug, revokeTokenAndExpire])
|
}, [apiRoute, revokeTokenAndExpire, serverURL, setNewUser, userSlug])
|
||||||
|
|
||||||
const refreshPermissions = useCallback(
|
const refreshPermissions = useCallback(
|
||||||
async ({ locale }: { locale?: string } = {}) => {
|
async ({ locale }: { locale?: string } = {}) => {
|
||||||
@@ -235,20 +243,9 @@ export function AuthProvider({
|
|||||||
|
|
||||||
if (request.status === 200) {
|
if (request.status === 200) {
|
||||||
const json: MeOperationResult = await request.json()
|
const json: MeOperationResult = await request.json()
|
||||||
let user = null
|
const user = null
|
||||||
|
|
||||||
if (json?.user) {
|
|
||||||
setUser(json.user)
|
|
||||||
user = json.user
|
|
||||||
|
|
||||||
if (json?.token) {
|
|
||||||
setTokenAndExpiration(json)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setUser(null)
|
|
||||||
revokeTokenAndExpire()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
setNewUser(json)
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -256,7 +253,7 @@ export function AuthProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}, [serverURL, apiRoute, userSlug, i18n.language, setTokenAndExpiration, revokeTokenAndExpire])
|
}, [serverURL, apiRoute, userSlug, i18n.language, setNewUser])
|
||||||
|
|
||||||
// On mount, get user and set
|
// On mount, get user and set
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -270,58 +267,43 @@ export function AuthProvider({
|
|||||||
}
|
}
|
||||||
}, [debouncedLocationChange, refreshCookie, id])
|
}, [debouncedLocationChange, refreshCookie, id])
|
||||||
|
|
||||||
// When initialUser changes, reset in state
|
|
||||||
useEffect(() => {
|
|
||||||
setUser(initialUser)
|
|
||||||
}, [initialUser])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLastLocationChange(Date.now())
|
setLastLocationChange(Date.now())
|
||||||
}, [pathname])
|
}, [pathname])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let reminder: ReturnType<typeof setTimeout>
|
let reminder: ReturnType<typeof setTimeout>
|
||||||
|
let forceLogOut: ReturnType<typeof setTimeout>
|
||||||
const now = Math.round(new Date().getTime() / 1000)
|
const now = Math.round(new Date().getTime() / 1000)
|
||||||
const remainingTime = typeof tokenExpiration === 'number' ? tokenExpiration - now : 0
|
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) {
|
if (remainingTime > 0) {
|
||||||
reminder = setTimeout(
|
reminder = setTimeout(() => {
|
||||||
() => {
|
openModal(stayLoggedInModalSlug)
|
||||||
openModal(stayLoggedInModalSlug)
|
}, remindInTimeFromNow)
|
||||||
},
|
|
||||||
Math.max(Math.min((remainingTime - 60) * 1000, maxTimeoutTime)),
|
forceLogOut = setTimeout(() => {
|
||||||
)
|
setNewUser(null)
|
||||||
|
}, forceLogOutInTimeFromNow)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (reminder) {
|
if (reminder) {
|
||||||
clearTimeout(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) {
|
if (forceLogOut) {
|
||||||
clearTimeout(forceLogOut)
|
clearTimeout(forceLogOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [tokenExpiration, closeAllModals, i18n, redirectToInactivityRoute, revokeTokenAndExpire])
|
}, [tokenExpiration, openModal, i18n, setNewUser, user])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider
|
<Context.Provider
|
||||||
@@ -333,7 +315,7 @@ export function AuthProvider({
|
|||||||
refreshCookieAsync,
|
refreshCookieAsync,
|
||||||
refreshPermissions,
|
refreshPermissions,
|
||||||
setPermissions,
|
setPermissions,
|
||||||
setUser,
|
setUser: setNewUser,
|
||||||
token: tokenInMemory,
|
token: tokenInMemory,
|
||||||
user,
|
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