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:
Jarrod Flesch
2024-10-08 11:49:18 -04:00
committed by GitHub
parent f6e5244204
commit 829996a126
4 changed files with 99 additions and 106 deletions

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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 [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 { 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 (!isLoggingOut) {
setIsLoggingOut(true)
void logOut()
if (!isLoggedOut) {
void handleLogOut()
}
}, [isLoggingOut, logOut])
}, [handleLogOut, isLoggedOut])
if (isLoggingOut) {
if (isLoggedOut && inactivity) {
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({
adminRoute,
path: `/login${
redirect && redirect.length > 0 ? `?redirect=${encodeURIComponent(redirect)}` : ''
}`,
})}
>
<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')} />
}

View File

@@ -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(
() => {
openModal(stayLoggedInModalSlug)
},
Math.max(Math.min((remainingTime - 60) * 1000, maxTimeoutTime)),
)
reminder = setTimeout(() => {
openModal(stayLoggedInModalSlug)
}, 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)