diff --git a/packages/next/src/views/CreateFirstUser/index.client.tsx b/packages/next/src/views/CreateFirstUser/index.client.tsx index 4a88484f9..037431e98 100644 --- a/packages/next/src/views/CreateFirstUser/index.client.tsx +++ b/packages/next/src/views/CreateFirstUser/index.client.tsx @@ -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 ( diff --git a/packages/next/src/views/Login/LoginForm/index.tsx b/packages/next/src/views/Login/LoginForm/index.tsx index dfda6e81f..f3ef1edeb 100644 --- a/packages/next/src/views/Login/LoginForm/index.tsx +++ b/packages/next/src/views/Login/LoginForm/index.tsx @@ -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 ( diff --git a/packages/next/src/views/Logout/LogoutClient.tsx b/packages/next/src/views/Logout/LogoutClient.tsx index c9cbc2956..43cee5044 100644 --- a/packages/next/src/views/Logout/LogoutClient.tsx +++ b/packages/next/src/views/Logout/LogoutClient.tsx @@ -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(undefined) + const [isLoggedOut, setIsLoggedOut] = React.useState(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 (
- {inactivity &&

{t('authentication:loggedOutInactivity')}

} - {!inactivity &&

{t('authentication:loggedOutSuccessfully')}

} -
) } - return {t('authentication:loggingOut')} + return } diff --git a/packages/ui/src/providers/Auth/index.tsx b/packages/ui/src/providers/Auth/index.tsx index fa5b90290..7107caf48 100644 --- a/packages/ui/src/providers/Auth/index.tsx +++ b/packages/ui/src/providers/Auth/index.tsx @@ -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 = { +export type UserResponse = MeOperationResult | null + +export type AuthContext = { fetchFullUser: () => Promise - logOut: () => Promise + logOut: () => Promise permissions?: Permissions refreshCookie: (forceRefresh?: boolean) => void refreshCookieAsync: () => Promise refreshPermissions: () => Promise 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(initialUser) + const [user, setUserInMemory] = useState(initialUser) const [tokenInMemory, setTokenInMemory] = useState() const [tokenExpiration, setTokenExpiration] = useState() 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>(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 + let forceLogOut: ReturnType 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 - 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 ( (): AuthContext => useContext(Context) as AuthContext +export const useAuth = (): AuthContext => useContext(Context)