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

View File

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

View File

@@ -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')} />
} }

View File

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