diff --git a/src/admin/components/utilities/Auth/index.tsx b/src/admin/components/utilities/Auth/index.tsx index 7d7a7af9b4..662f4e0034 100644 --- a/src/admin/components/utilities/Auth/index.tsx +++ b/src/admin/components/utilities/Auth/index.tsx @@ -1,5 +1,4 @@ import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import jwtDecode from 'jwt-decode'; import { useHistory, useLocation } from 'react-router-dom'; import { useModal } from '@faceless-ui/modal'; import { useTranslation } from 'react-i18next'; @@ -17,6 +16,7 @@ const maxTimeoutTime = 2147483647; export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [user, setUser] = useState(); const [tokenInMemory, setTokenInMemory] = useState(); + const [tokenExpiration, setTokenExpiration] = useState(); const { pathname } = useLocation(); const { push } = useHistory(); @@ -35,7 +35,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, } = config; - const exp = user?.exp; + const exp = tokenExpiration; const [permissions, setPermissions] = useState(); @@ -56,9 +56,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children closeAllModals(); }, [push, admin, logoutInactivityRoute, closeAllModals]); + const revokeTokenAndExpire = useCallback(() => { + setTokenInMemory(undefined); + setTokenExpiration(undefined); + }, []); + + const setTokenAndExpiration = useCallback((json) => { + const token = json?.token || json?.refreshedToken; + if (token && json?.exp) { + setTokenInMemory(token); + setTokenExpiration(json.exp); + } else { + revokeTokenAndExpire(); + } + }, [revokeTokenAndExpire]); + const refreshCookie = useCallback((forceRefresh?: boolean) => { const now = Math.round((new Date()).getTime() / 1000); - const remainingTime = (exp as number || 0) - now; + const remainingTime = (typeof exp === 'number' ? exp : 0) - now; if (forceRefresh || (exp && remainingTime < 120)) { setTimeout(async () => { @@ -72,6 +87,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children if (request.status === 200) { const json = await request.json(); setUser(json.user); + setTokenAndExpiration(json); } else { setUser(null); redirectToInactivityRoute(); @@ -81,7 +97,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, 1000); } - }, [exp, serverURL, api, userSlug, i18n, redirectToInactivityRoute]); + }, [serverURL, api, userSlug, i18n, exp, redirectToInactivityRoute, setTokenAndExpiration]); const refreshCookieAsync = useCallback(async (skipSetUser?: boolean): Promise => { try { @@ -93,7 +109,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children if (request.status === 200) { const json = await request.json(); - if (!skipSetUser) setUser(json.user); + if (!skipSetUser) { + setUser(json.user); + setTokenAndExpiration(json); + } return json.user; } @@ -104,19 +123,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children toast.error(`Refreshing token failed: ${e.message}`); return null; } - }, [serverURL, api, userSlug, i18n, redirectToInactivityRoute]); - - const setToken = useCallback((token: string) => { - const decoded = jwtDecode(token); - setUser(decoded); - setTokenInMemory(token); - }, []); + }, [serverURL, api, userSlug, i18n, redirectToInactivityRoute, setTokenAndExpiration]); const logOut = useCallback(() => { setUser(null); - setTokenInMemory(undefined); + revokeTokenAndExpire(); requests.post(`${serverURL}${api}/${userSlug}/logout`); - }, [serverURL, api, userSlug]); + }, [serverURL, api, userSlug, revokeTokenAndExpire]); const refreshPermissions = useCallback(async () => { try { @@ -137,56 +150,59 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, [serverURL, api, i18n]); - // On mount, get user and set - useEffect(() => { - const fetchMe = async () => { - try { - const request = await requests.get(`${serverURL}${api}/${userSlug}/me`, { - headers: { - 'Accept-Language': i18n.language, - }, - }); + const fetchFullUser = React.useCallback(async () => { + try { + const request = await requests.get(`${serverURL}${api}/${userSlug}/me`, { + headers: { + 'Accept-Language': i18n.language, + }, + }); - if (request.status === 200) { - const json = await request.json(); + if (request.status === 200) { + const json = await request.json(); - if (json?.user) { - setUser(json.user); - } else if (json?.token) { - setToken(json.token); - } else if (autoLogin && autoLogin.prefillOnly !== true) { - // auto log-in with the provided autoLogin credentials. This is used in dev mode - // so you don't have to log in over and over again - const autoLoginResult = await requests.post(`${serverURL}${api}/${userSlug}/login`, { - body: JSON.stringify({ - email: autoLogin.email, - password: autoLogin.password, - }), - headers: { - 'Accept-Language': i18n.language, - 'Content-Type': 'application/json', - }, - }); - if (autoLoginResult.status === 200) { - const autoLoginJson = await autoLoginResult.json(); - setUser(autoLoginJson.user); - if (autoLoginJson?.token) { - setToken(autoLoginJson.token); - } - } else { - setUser(null); + if (json?.user) { + setUser(json.user); + if (json?.token) { + setTokenAndExpiration(json); + } + } else if (autoLogin && autoLogin.prefillOnly !== true) { + // auto log-in with the provided autoLogin credentials. This is used in dev mode + // so you don't have to log in over and over again + const autoLoginResult = await requests.post(`${serverURL}${api}/${userSlug}/login`, { + body: JSON.stringify({ + email: autoLogin.email, + password: autoLogin.password, + }), + headers: { + 'Accept-Language': i18n.language, + 'Content-Type': 'application/json', + }, + }); + if (autoLoginResult.status === 200) { + const autoLoginJson = await autoLoginResult.json(); + setUser(autoLoginJson.user); + if (autoLoginJson?.token) { + setTokenAndExpiration(autoLoginJson); } } else { setUser(null); + revokeTokenAndExpire(); } + } else { + setUser(null); + revokeTokenAndExpire(); } - } catch (e) { - toast.error(`Fetching user failed: ${e.message}`); } - }; + } catch (e) { + toast.error(`Fetching user failed: ${e.message}`); + } + }, [serverURL, api, userSlug, i18n, autoLogin, setTokenAndExpiration, revokeTokenAndExpire]); - fetchMe(); - }, [i18n, setToken, api, serverURL, userSlug, autoLogin]); + // On mount, get user and set + useEffect(() => { + fetchFullUser(); + }, [fetchFullUser]); // When location changes, refresh cookie useEffect(() => { @@ -209,12 +225,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children useEffect(() => { let reminder: ReturnType; const now = Math.round((new Date()).getTime() / 1000); - const remainingTime = exp as number - now; + const remainingTime = typeof exp === 'number' ? exp - now : 0; if (remainingTime > 0) { reminder = setTimeout(() => { openModal('stay-logged-in'); - }, (Math.min((remainingTime - 60) * 1000), maxTimeoutTime)); + }, Math.max(Math.min((remainingTime - 60) * 1000, maxTimeoutTime))); } return () => { @@ -225,19 +241,20 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children useEffect(() => { let forceLogOut: ReturnType; const now = Math.round((new Date()).getTime() / 1000); - const remainingTime = exp as number - now; + const remainingTime = typeof exp === 'number' ? exp - now : 0; if (remainingTime > 0) { forceLogOut = setTimeout(() => { setUser(null); + revokeTokenAndExpire(); redirectToInactivityRoute(); - }, Math.min(remainingTime * 1000, maxTimeoutTime)); + }, Math.max(Math.min(remainingTime * 1000, maxTimeoutTime), 0)); } return () => { if (forceLogOut) clearTimeout(forceLogOut); }; - }, [exp, closeAllModals, i18n, redirectToInactivityRoute]); + }, [exp, closeAllModals, i18n, redirectToInactivityRoute, revokeTokenAndExpire]); return ( = ({ children refreshCookieAsync, refreshPermissions, permissions, - setToken, token: tokenInMemory, + fetchFullUser, }} > {children} diff --git a/src/admin/components/utilities/Auth/types.ts b/src/admin/components/utilities/Auth/types.ts index 386e730087..2ea556cfa9 100644 --- a/src/admin/components/utilities/Auth/types.ts +++ b/src/admin/components/utilities/Auth/types.ts @@ -6,8 +6,8 @@ export type AuthContext = { logOut: () => void refreshCookie: (forceRefresh?: boolean) => void refreshCookieAsync: () => Promise - setToken: (token: string) => void token?: string refreshPermissions: () => Promise permissions?: Permissions + fetchFullUser: () => Promise } diff --git a/src/admin/components/views/CreateFirstUser/index.tsx b/src/admin/components/views/CreateFirstUser/index.tsx index b0bc3c6c53..5eea25f9e5 100644 --- a/src/admin/components/views/CreateFirstUser/index.tsx +++ b/src/admin/components/views/CreateFirstUser/index.tsx @@ -17,7 +17,7 @@ const baseClass = 'create-first-user'; const CreateFirstUser: React.FC = (props) => { const { setInitialized } = props; - const { setToken } = useAuth(); + const { fetchFullUser } = useAuth(); const { admin: { user: userSlug }, collections, serverURL, routes: { admin, api }, } = useConfig(); @@ -25,9 +25,9 @@ const CreateFirstUser: React.FC = (props) => { const userConfig = collections.find((collection) => collection.slug === userSlug); - const onSuccess = (json) => { + const onSuccess = async (json) => { if (json?.user?.token) { - setToken(json.user.token); + await fetchFullUser(); } setInitialized(true); diff --git a/src/admin/components/views/Login/index.tsx b/src/admin/components/views/Login/index.tsx index 7262622177..3dc8b9fd54 100644 --- a/src/admin/components/views/Login/index.tsx +++ b/src/admin/components/views/Login/index.tsx @@ -20,7 +20,7 @@ const baseClass = 'login'; const Login: React.FC = () => { const history = useHistory(); const { t } = useTranslation('authentication'); - const { user, setToken } = useAuth(); + const { user, fetchFullUser } = useAuth(); const config = useConfig(); const { admin: { @@ -47,9 +47,9 @@ const Login: React.FC = () => { const redirect = query.get('redirect'); - const onSuccess = (data) => { + const onSuccess = async (data) => { if (data.token) { - setToken(data.token); + await fetchFullUser(); // Ensure the redirect always starts with the admin route, and concatenate the redirect path history.push(admin + (redirect || '')); diff --git a/src/admin/components/views/ResetPassword/index.tsx b/src/admin/components/views/ResetPassword/index.tsx index 9e5bf5ee29..0125662969 100644 --- a/src/admin/components/views/ResetPassword/index.tsx +++ b/src/admin/components/views/ResetPassword/index.tsx @@ -22,12 +22,12 @@ const ResetPassword: React.FC = () => { const { admin: { user: userSlug, logoutRoute }, serverURL, routes: { admin, api } } = config; const { token } = useParams<{ token?: string }>(); const history = useHistory(); - const { user, setToken } = useAuth(); + const { user, fetchFullUser } = useAuth(); const { t } = useTranslation('authentication'); - const onSuccess = (data) => { + const onSuccess = async (data) => { if (data.token) { - setToken(data.token); + await fetchFullUser(); history.push(`${admin}`); } else { history.push(`${admin}/login`); diff --git a/test/auth/config.ts b/test/auth/config.ts index 4588670f63..2c4d372713 100644 --- a/test/auth/config.ts +++ b/test/auth/config.ts @@ -14,8 +14,8 @@ export default buildConfigWithDefaults({ admin: { user: 'users', autoLogin: { - email: 'test@example.com', - password: 'test', + email: devUser.email, + password: devUser.password, prefillOnly: true, }, },