diff --git a/src/App.tsx b/src/App.tsx index 1c01f93ae2..3daf57b09c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Route, Switch } from 'react-router-dom'; import { useQuery } from '@apollo/client'; import * as installedPlugins from 'components/plugins/index'; @@ -63,6 +63,23 @@ function app(): JSX.Element { const { data, loading } = useQuery(CHECK_AUTH); + useEffect(() => { + if (data) { + localStorage.setItem( + 'name', + `${data.checkAuth.firstName} ${data.checkAuth.lastName}` + ); + localStorage.setItem('id', data.checkAuth._id); + localStorage.setItem('email', data.checkAuth.email); + localStorage.setItem('IsLoggedIn', 'TRUE'); + localStorage.setItem('UserType', data.checkAuth.userType); + localStorage.setItem('FirstName', data.checkAuth.firstName); + localStorage.setItem('LastName', data.checkAuth.lastName); + localStorage.setItem('UserImage', data.checkAuth.image); + localStorage.setItem('Email', data.checkAuth.email); + } + }, [data, loading]); + const extraRoutes = Object.entries(installedPlugins).map( (plugin: any, index) => { const extraComponent = plugin[1]; @@ -79,24 +96,6 @@ function app(): JSX.Element { if (loading) { return ; } - - if (data) { - localStorage.setItem( - 'name', - `${data.checkAuth.firstName} ${data.checkAuth.lastName}` - ); - localStorage.setItem('id', data.checkAuth._id); - localStorage.setItem('email', data.checkAuth.email); - localStorage.setItem('IsLoggedIn', 'TRUE'); - localStorage.setItem('UserType', data.checkAuth.userType); - localStorage.setItem('FirstName', data.checkAuth.firstName); - localStorage.setItem('LastName', data.checkAuth.lastName); - localStorage.setItem('UserImage', data.checkAuth.image); - localStorage.setItem('Email', data.checkAuth.email); - } else { - localStorage.clear(); - } - return ( <> diff --git a/src/GraphQl/Mutations/mutations.ts b/src/GraphQl/Mutations/mutations.ts index 34abda2b26..720d655b53 100644 --- a/src/GraphQl/Mutations/mutations.ts +++ b/src/GraphQl/Mutations/mutations.ts @@ -150,6 +150,25 @@ export const LOGIN_MUTATION = gql` } `; +// to get the refresh token + +export const REFRESH_TOKEN_MUTATION = gql` + mutation RefreshToken($refreshToken: String!) { + refreshToken(refreshToken: $refreshToken) { + refreshToken + accessToken + } + } +`; + +// to revoke a refresh token + +export const REVOKE_REFRESH_TOKEN = gql` + mutation RevokeRefreshTokenForUser { + revokeRefreshTokenForUser + } +`; + // To verify the google recaptcha export const RECAPTCHA_MUTATION = gql` diff --git a/src/components/LeftDrawer/LeftDrawer.test.tsx b/src/components/LeftDrawer/LeftDrawer.test.tsx index bd7652dab4..a5aa6c1851 100644 --- a/src/components/LeftDrawer/LeftDrawer.test.tsx +++ b/src/components/LeftDrawer/LeftDrawer.test.tsx @@ -8,6 +8,9 @@ import { BrowserRouter } from 'react-router-dom'; import i18nForTest from 'utils/i18nForTest'; import type { InterfaceLeftDrawerProps } from './LeftDrawer'; import LeftDrawer from './LeftDrawer'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { MockedProvider } from '@apollo/react-testing'; const props = { hideDrawer: true, @@ -29,6 +32,17 @@ const propsUsers: InterfaceLeftDrawerProps = { screenName: 'Users', }; +const MOCKS = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: {}, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + jest.mock('react-toastify', () => ({ toast: { success: jest.fn(), @@ -56,11 +70,13 @@ describe('Testing Left Drawer component for SUPERADMIN', () => { localStorage.setItem('UserImage', ''); localStorage.setItem('UserType', 'SUPERADMIN'); render( - - - - - + + + + + + + ); expect(screen.getByText('Organizations')).toBeInTheDocument(); @@ -97,26 +113,16 @@ describe('Testing Left Drawer component for SUPERADMIN', () => { test('Testing in requests screen', () => { localStorage.setItem('UserType', 'SUPERADMIN'); render( - - - - - + + + + + + + ); const orgsBtn = screen.getByTestId(/orgsBtn/i); - const requestsBtn = screen.getByTestId(/requestsBtn/i); - const rolesBtn = screen.getByTestId(/rolesBtn/i); - - expect( - requestsBtn.className.includes('text-white btn btn-success') - ).toBeTruthy(); - expect( - orgsBtn.className.includes('text-secondary btn btn-light') - ).toBeTruthy(); - expect( - rolesBtn.className.includes('text-secondary btn btn-light') - ).toBeTruthy(); // Send to organizations screen userEvent.click(orgsBtn); @@ -126,11 +132,13 @@ describe('Testing Left Drawer component for SUPERADMIN', () => { test('Testing in roles screen', () => { localStorage.setItem('UserType', 'SUPERADMIN'); render( - - - - - + + + + + + + ); const orgsBtn = screen.getByTestId(/orgsBtn/i); @@ -155,11 +163,13 @@ describe('Testing Left Drawer component for SUPERADMIN', () => { test('Testing Drawer open close functionality', () => { localStorage.setItem('UserType', 'SUPERADMIN'); render( - - - - - + + + + + + + ); const closeModalBtn = screen.getByTestId(/closeModalBtn/i); userEvent.click(closeModalBtn); @@ -168,33 +178,39 @@ describe('Testing Left Drawer component for SUPERADMIN', () => { test('Testing Drawer when hideDrawer is null', () => { localStorage.setItem('UserType', 'SUPERADMIN'); render( - - - - - + + + + + + + ); }); test('Testing Drawer when hideDrawer is true', () => { localStorage.setItem('UserType', 'SUPERADMIN'); render( - - - - - + + + + + + + ); }); test('Testing logout functionality', async () => { localStorage.setItem('UserType', 'SUPERADMIN'); render( - - - - - + + + + + + + ); userEvent.click(screen.getByTestId('logoutBtn')); expect(localStorage.clear).toHaveBeenCalled(); @@ -206,11 +222,13 @@ describe('Testing Left Drawer component for ADMIN', () => { test('Components should be rendered properly', () => { localStorage.setItem('UserType', 'ADMIN'); render( - - - - - + + + + + + + ); expect(screen.getByText('Organizations')).toBeInTheDocument(); diff --git a/src/components/LeftDrawer/LeftDrawer.tsx b/src/components/LeftDrawer/LeftDrawer.tsx index d0b6220021..d8708e2327 100644 --- a/src/components/LeftDrawer/LeftDrawer.tsx +++ b/src/components/LeftDrawer/LeftDrawer.tsx @@ -9,6 +9,8 @@ import { ReactComponent as RequestsIcon } from 'assets/svgs/requests.svg'; import { ReactComponent as RolesIcon } from 'assets/svgs/roles.svg'; import { ReactComponent as TalawaLogo } from 'assets/svgs/talawa.svg'; import styles from './LeftDrawer.module.css'; +import { useMutation } from '@apollo/client'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; export interface InterfaceLeftDrawerProps { hideDrawer: boolean | null; @@ -30,7 +32,10 @@ const leftDrawer = ({ const userId = localStorage.getItem('id'); const history = useHistory(); + const [revokeRefreshToken] = useMutation(REVOKE_REFRESH_TOKEN); + const logout = (): void => { + revokeRefreshToken(); localStorage.clear(); history.push('/'); }; diff --git a/src/components/LeftDrawerEvent/LeftDrawerEvent.test.tsx b/src/components/LeftDrawerEvent/LeftDrawerEvent.test.tsx index eb4a449a67..3ce94b2a1d 100644 --- a/src/components/LeftDrawerEvent/LeftDrawerEvent.test.tsx +++ b/src/components/LeftDrawerEvent/LeftDrawerEvent.test.tsx @@ -10,6 +10,7 @@ import LeftDrawerEvent, { } from './LeftDrawerEvent'; import { MockedProvider } from '@apollo/react-testing'; import { EVENT_FEEDBACKS } from 'GraphQl/Queries/Queries'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; const props: InterfaceLeftDrawerProps = { event: { @@ -40,6 +41,12 @@ const props2: InterfaceLeftDrawerProps = { }; const mocks = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: {}, + }, { request: { query: EVENT_FEEDBACKS, diff --git a/src/components/LeftDrawerEvent/LeftDrawerEvent.tsx b/src/components/LeftDrawerEvent/LeftDrawerEvent.tsx index 883ffc6f6e..795f6016cb 100644 --- a/src/components/LeftDrawerEvent/LeftDrawerEvent.tsx +++ b/src/components/LeftDrawerEvent/LeftDrawerEvent.tsx @@ -9,6 +9,8 @@ import IconComponent from 'components/IconComponent/IconComponent'; import { EventRegistrantsWrapper } from 'components/EventRegistrantsModal/EventRegistrantsWrapper'; import { CheckInWrapper } from 'components/CheckIn/CheckInWrapper'; import { EventStatsWrapper } from 'components/EventStats/EventStatsWrapper'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import { useMutation } from '@apollo/client'; export interface InterfaceLeftDrawerProps { event: { @@ -30,6 +32,7 @@ const leftDrawerEvent = ({ setHideDrawer, setShowAddEventProjectModal, }: InterfaceLeftDrawerProps): JSX.Element => { + const [revokeRefreshToken] = useMutation(REVOKE_REFRESH_TOKEN); const userType = localStorage.getItem('UserType'); const firstName = localStorage.getItem('FirstName'); const lastName = localStorage.getItem('LastName'); @@ -38,6 +41,7 @@ const leftDrawerEvent = ({ const history = useHistory(); const logout = (): void => { + revokeRefreshToken(); localStorage.clear(); history.push('/'); }; diff --git a/src/components/LeftDrawerOrg/LeftDrawerOrg.test.tsx b/src/components/LeftDrawerOrg/LeftDrawerOrg.test.tsx index 4d4d0db4b5..101f4133e2 100644 --- a/src/components/LeftDrawerOrg/LeftDrawerOrg.test.tsx +++ b/src/components/LeftDrawerOrg/LeftDrawerOrg.test.tsx @@ -14,6 +14,7 @@ import { store } from 'state/store'; import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; import { act } from 'react-dom/test-utils'; import { StaticMockLink } from 'utils/StaticMockLink'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; const props: InterfaceLeftDrawerProps = { screenName: 'Dashboard', @@ -63,6 +64,12 @@ const props: InterfaceLeftDrawerProps = { }; const MOCKS = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: {}, + }, { request: { query: ORGANIZATIONS_LIST, diff --git a/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx b/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx index 73c5e4364d..aa263f7e12 100644 --- a/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx +++ b/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client'; import { WarningAmberOutlined } from '@mui/icons-material'; import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; import CollapsibleDropdown from 'components/CollapsibleDropdown/CollapsibleDropdown'; @@ -13,6 +13,7 @@ import { ReactComponent as AngleRightIcon } from 'assets/svgs/angleRight.svg'; import { ReactComponent as LogoutIcon } from 'assets/svgs/logout.svg'; import { ReactComponent as TalawaLogo } from 'assets/svgs/talawa.svg'; import styles from './LeftDrawerOrg.module.css'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; export interface InterfaceLeftDrawerProps { orgId: string; @@ -44,6 +45,8 @@ const leftDrawerOrg = ({ variables: { id: orgId }, }); + const [revokeRefreshToken] = useMutation(REVOKE_REFRESH_TOKEN); + const userType = localStorage.getItem('UserType'); const firstName = localStorage.getItem('FirstName'); const lastName = localStorage.getItem('LastName'); @@ -63,6 +66,7 @@ const leftDrawerOrg = ({ }, [data]); const logout = (): void => { + revokeRefreshToken(); localStorage.clear(); history.push('/'); }; diff --git a/src/components/UserPortal/UserNavbar/UserNavbar.test.tsx b/src/components/UserPortal/UserNavbar/UserNavbar.test.tsx index ed51680185..57d478b8d0 100644 --- a/src/components/UserPortal/UserNavbar/UserNavbar.test.tsx +++ b/src/components/UserPortal/UserNavbar/UserNavbar.test.tsx @@ -11,6 +11,7 @@ import { StaticMockLink } from 'utils/StaticMockLink'; import UserNavbar from './UserNavbar'; import userEvent from '@testing-library/user-event'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; async function wait(ms = 100): Promise { await act(() => { @@ -20,7 +21,16 @@ async function wait(ms = 100): Promise { }); } -const link = new StaticMockLink([], true); +const MOCKS = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: {}, + }, +]; + +const link = new StaticMockLink(MOCKS, true); describe('Testing UserNavbar Component [User Portal]', () => { afterEach(async () => { diff --git a/src/components/UserPortal/UserNavbar/UserNavbar.tsx b/src/components/UserPortal/UserNavbar/UserNavbar.tsx index 79779cb514..e10268e85e 100644 --- a/src/components/UserPortal/UserNavbar/UserNavbar.tsx +++ b/src/components/UserPortal/UserNavbar/UserNavbar.tsx @@ -9,12 +9,16 @@ import PermIdentityIcon from '@mui/icons-material/PermIdentity'; import LanguageIcon from '@mui/icons-material/Language'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; +import { useMutation } from '@apollo/client'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; function userNavbar(): JSX.Element { const { t } = useTranslation('translation', { keyPrefix: 'userNavbar', }); + const [revokeRefreshToken] = useMutation(REVOKE_REFRESH_TOKEN); + const [currentLanguageCode, setCurrentLanguageCode] = React.useState( /* istanbul ignore next */ cookies.get('i18next') || 'en' @@ -22,6 +26,7 @@ function userNavbar(): JSX.Element { /* istanbul ignore next */ const handleLogout = (): void => { + revokeRefreshToken(); localStorage.clear(); window.location.replace('/user'); }; diff --git a/src/index.tsx b/src/index.tsx index 6471e29e21..3d528d9d77 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,10 +30,34 @@ import { BACKEND_URL, REACT_APP_BACKEND_WEBSOCKET_URL, } from 'Constant/constant'; +import { refreshToken } from 'utils/getRefreshToken'; -onError(({ graphQLErrors }) => { - if (graphQLErrors) graphQLErrors.map(({ message }) => console.log(message)); -}); +const errorLink = onError( + ({ graphQLErrors, networkError, operation, forward }) => { + if (graphQLErrors) { + graphQLErrors.map(({ message }) => { + if (message === 'User is not authenticated') { + refreshToken().then((success) => { + if (success) { + const oldHeaders = operation.getContext().headers; + operation.setContext({ + headers: { + ...oldHeaders, + authorization: 'Bearer ' + localStorage.getItem('token'), + }, + }); + return forward(operation); + } else { + localStorage.clear(); + } + }); + } + }); + } else if (networkError) { + console.log(`[Network error]: ${networkError}`); + } + } +); const httpLink = new HttpLink({ uri: BACKEND_URL, @@ -66,7 +90,7 @@ const splitLink = split( ); const client: ApolloClient = new ApolloClient({ cache: new InMemoryCache(), - link: splitLink, + link: errorLink.concat(splitLink), }); const fallbackLoader =
; diff --git a/src/screens/LoginPage/LoginPage.tsx b/src/screens/LoginPage/LoginPage.tsx index a7f4029b92..481f8d09c2 100644 --- a/src/screens/LoginPage/LoginPage.tsx +++ b/src/screens/LoginPage/LoginPage.tsx @@ -191,6 +191,7 @@ function loginPage(): JSX.Element { loginData.login.user.adminApproved === true) ) { localStorage.setItem('token', loginData.login.accessToken); + localStorage.setItem('refreshToken', loginData.login.refreshToken); localStorage.setItem('id', loginData.login.user._id); localStorage.setItem('IsLoggedIn', 'TRUE'); localStorage.setItem('UserType', loginData.login.user.userType); diff --git a/src/utils/getRefreshToken.test.ts b/src/utils/getRefreshToken.test.ts new file mode 100644 index 0000000000..12d8448b76 --- /dev/null +++ b/src/utils/getRefreshToken.test.ts @@ -0,0 +1,52 @@ +import { refreshToken } from './getRefreshToken'; + +jest.mock('@apollo/client', () => { + const originalModule = jest.requireActual('@apollo/client'); + + return { + __esModule: true, + ...originalModule, + ApolloClient: jest.fn(() => ({ + mutate: jest.fn(() => + Promise.resolve({ + data: { + refreshToken: { + accessToken: 'newAccessToken', + refreshToken: 'newRefreshToken', + }, + }, + }) + ), + })), + }; +}); + +describe('refreshToken', () => { + // Mock window.location.reload() + const { location } = window; + delete (global.window as any).location; + global.window.location = { ...location, reload: jest.fn() }; + + // Mock localStorage.setItem() and localStorage.clear() + Storage.prototype.setItem = jest.fn(); + Storage.prototype.clear = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when the token is refreshed successfully', async () => { + const result = await refreshToken(); + + expect(localStorage.setItem).toHaveBeenCalledWith( + 'token', + 'newAccessToken' + ); + expect(localStorage.setItem).toHaveBeenCalledWith( + 'refreshToken', + 'newRefreshToken' + ); + expect(result).toBe(true); + expect(window.location.reload).toHaveBeenCalled(); + }); +}); diff --git a/src/utils/getRefreshToken.ts b/src/utils/getRefreshToken.ts new file mode 100644 index 0000000000..f3145c4b48 --- /dev/null +++ b/src/utils/getRefreshToken.ts @@ -0,0 +1,31 @@ +import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'; +import { BACKEND_URL } from 'Constant/constant'; +import { REFRESH_TOKEN_MUTATION } from 'GraphQl/Mutations/mutations'; + +export async function refreshToken(): Promise { + const client = new ApolloClient({ + link: new HttpLink({ + uri: BACKEND_URL, + }), + cache: new InMemoryCache(), + }); + + const refreshToken = localStorage.getItem('refreshToken'); + /* istanbul ignore next */ + try { + const { data } = await client.mutate({ + mutation: REFRESH_TOKEN_MUTATION, + variables: { + refreshToken: refreshToken, + }, + }); + + localStorage.setItem('token', data.refreshToken.accessToken); + localStorage.setItem('refreshToken', data.refreshToken.refreshToken); + window.location.reload(); + return true; + } catch (error) { + console.error('Failed to refresh token', error); + return false; + } +}