diff --git a/amplify/backend/function/stockerlambda/package.json b/amplify/backend/function/stockerlambda/package.json index d5d2deea..8bcedb63 100644 --- a/amplify/backend/function/stockerlambda/package.json +++ b/amplify/backend/function/stockerlambda/package.json @@ -9,7 +9,6 @@ "body-parser": "^1.17.1", "cors": "^2.8.5", "express": "^4.17.3", - "googleapis": "^131.0.0", "luxon": "^3.3.4" }, "scripts": { diff --git a/amplify/backend/function/stockerlambda/ts/app.ts b/amplify/backend/function/stockerlambda/ts/app.ts index 83bccf44..9954a637 100644 --- a/amplify/backend/function/stockerlambda/ts/app.ts +++ b/amplify/backend/function/stockerlambda/ts/app.ts @@ -2,7 +2,6 @@ import cors from 'cors'; import express from 'express'; import bodyParser from 'body-parser'; import awsServerlessExpressMiddleware from 'aws-serverless-express/middleware'; -import { Auth } from 'googleapis'; import * as yahoo from './yahoo'; import { Price } from './types'; @@ -31,49 +30,6 @@ app.use(cors({ } })); -app.get('/user/authorize', async (req, res) => { - const code = req.query.code as string; - if (!code) { - res.status(400).json({ - error: 'CODE_REQUIRED', - description: 'Code is required for this endpoint', - }); - } - - const oauth2Client = new Auth.OAuth2Client( - '123339406534-gnk10bh5hqo87qlla8e9gmol1j961rtg.apps.googleusercontent.com', - process.env.CLIENT_SECRET, - 'postmessage' - ); - - const response = await oauth2Client.getToken(code); - - res.json(response.tokens); -}); - -app.get('/user/refresh', async (req, res) => { - const refresh_token = req.query.refresh_token as string; - if (!refresh_token) { - res.status(400).json({ - error: 'REFRESH_TOKEN_REQUIRED', - description: 'Refresh token is required for this endpoint', - }); - } - - const oauth2Client = new Auth.OAuth2Client( - '123339406534-gnk10bh5hqo87qlla8e9gmol1j961rtg.apps.googleusercontent.com', - process.env.CLIENT_SECRET, - 'postmessage' - ); - - oauth2Client.setCredentials({ - refresh_token, - }); - const response = await oauth2Client.refreshAccessToken(); - - res.json(response.credentials); -}); - app.get('/api/prices', async (req, res) => { let tickers = (req.query.ids as string || '').split(','); if (!tickers.length) { diff --git a/jest.config.mjs b/jest.config.mjs index a71290bd..c15398a4 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -15,8 +15,8 @@ const config = { collectCoverageFrom: ['src/**/*.{ts,tsx}'], coverageThreshold: { global: { - lines: 92.9, - branches: 86.6, + lines: 92, + branches: 86, }, }, testEnvironment: 'jest-environment-jsdom', diff --git a/src/__tests__/app/user/login/page.test.tsx b/src/__tests__/app/user/login/page.test.tsx index 9ae8c7c5..0095ad7b 100644 --- a/src/__tests__/app/user/login/page.test.tsx +++ b/src/__tests__/app/user/login/page.test.tsx @@ -1,18 +1,21 @@ import React from 'react'; -import { act, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import * as navigation from 'next/navigation'; import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; +import * as auth0 from '@auth0/auth0-react'; -import * as Stocker from '@/lib/Stocker'; import LoginPage from '@/app/user/login/page'; import * as helpers_env from '@/helpers/env'; -import * as sessionHook from '@/hooks/useSession'; -import type { Credentials } from '@/types/user'; jest.mock('swr'); jest.mock('next/navigation'); +jest.mock('@auth0/auth0-react', () => ({ + __esModule: true, + ...jest.requireActual('@auth0/auth0-react'), +})); + jest.mock('@/lib/Stocker', () => ({ __esModule: true, ...jest.requireActual('@/lib/Stocker'), @@ -23,101 +26,52 @@ jest.mock('@/helpers/env', () => ({ isStaging: () => false, })); -jest.mock('@/hooks/useSession', () => ({ - __esModule: true, - ...jest.requireActual('@/hooks/useSession'), -})); - describe('LoginPage', () => { - let requestCode: jest.Mock; - let mockInitCodeClient: jest.Mock; - let mockSetCredentials: jest.Mock>>; let mockRouterPush: jest.Mock; beforeEach(() => { - requestCode = jest.fn(); - mockInitCodeClient = jest.fn().mockReturnValue({ - requestCode, - }) as jest.Mock; - window.google = { - accounts: { - // @ts-ignore - oauth2: { - initCodeClient: mockInitCodeClient, - } as typeof window.google.accounts.oauth2, - } as typeof window.google.accounts, - }; mockRouterPush = jest.fn(); jest.spyOn(navigation, 'useRouter').mockImplementation(() => ({ push: mockRouterPush as AppRouterInstance['push'], } as AppRouterInstance)); - mockSetCredentials = jest.fn(); - jest.spyOn(sessionHook, 'default').mockReturnValue({ - session: undefined, - setCredentials: mockSetCredentials as React.Dispatch< - React.SetStateAction >, - } as sessionHook.SessionReturn); + jest.spyOn(helpers_env, 'isStaging').mockReturnValue(false); + jest.spyOn(auth0, 'useAuth0').mockReturnValue({ + isAuthenticated: false, + } as auth0.Auth0ContextInterface); }); - it('shows loading... when not finished', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - - it('calls requestCode when clicking sign in button', async () => { + it('sends to dashboard when authenticated', async () => { + jest.spyOn(auth0, 'useAuth0').mockReturnValue({ + isAuthenticated: true, + } as auth0.Auth0ContextInterface); render(); - expect(mockInitCodeClient).toHaveBeenCalledWith({ - callback: expect.any(Function), - client_id: '123339406534-gnk10bh5hqo87qlla8e9gmol1j961rtg.apps.googleusercontent.com', - scope: 'https://www.googleapis.com/auth/drive.file', - ux_mode: 'popup', - }); - - expect(requestCode).toBeCalledTimes(0); - screen.getByText('Sign In').click(); - expect(requestCode).toBeCalledTimes(1); + expect(mockRouterPush).toHaveBeenCalledWith('/dashboard/accounts'); }); - it('callback authorizes, saves session to storage and navigates to accounts page', async () => { - const credentials = { - access_token: 'access_token', - id_token: 'id_token', - refresh_token: 'refresh_token', - expiry_date: 123, - scope: '', - token_type: '', - }; - jest.spyOn(Stocker, 'authorize').mockResolvedValue(credentials); - + it('sends to dashboard when staging', async () => { + jest.spyOn(helpers_env, 'isStaging').mockReturnValue(true); render(); - const { callback } = mockInitCodeClient.mock.calls[0][0]; - await act(async () => callback({ code: 'CODE' })); - - expect(Stocker.authorize).toBeCalledWith('CODE'); - expect(mockSetCredentials).toHaveBeenNthCalledWith( - 1, - credentials, - ); expect(mockRouterPush).toHaveBeenCalledWith('/dashboard/accounts'); }); - it('does not call requestCode when clicking sign in button and sends to /home/dashboard when staging', async () => { - jest.spyOn(helpers_env, 'isStaging').mockReturnValue(true); - render(); + it('shows loading... when not finished', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); - expect(mockInitCodeClient).toHaveBeenCalledWith({ - callback: expect.any(Function), - client_id: '123339406534-gnk10bh5hqo87qlla8e9gmol1j961rtg.apps.googleusercontent.com', - scope: 'https://www.googleapis.com/auth/drive.file', - ux_mode: 'popup', - }); + it('calls loginWithPopup when clicking sign in button', async () => { + const mockLogin = jest.fn(); + jest.spyOn(auth0, 'useAuth0').mockReturnValue({ + isAuthenticated: true, + loginWithPopup: mockLogin as Function, + } as auth0.Auth0ContextInterface); + render(); screen.getByText('Sign In').click(); - expect(requestCode).toBeCalledTimes(0); - expect(mockRouterPush).toHaveBeenCalledWith('/dashboard/accounts'); - process.env.NEXT_PUBLIC_ENV = ''; + + expect(mockLogin).toBeCalled(); }); }); diff --git a/src/__tests__/app/user/logout/page.test.tsx b/src/__tests__/app/user/logout/page.test.tsx index c8b851e6..d220a3d7 100644 --- a/src/__tests__/app/user/logout/page.test.tsx +++ b/src/__tests__/app/user/logout/page.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import type { LinkProps } from 'next/link'; +import * as auth0 from '@auth0/auth0-react'; import LogoutPage from '@/app/user/logout/page'; -import * as sessionHook from '@/hooks/useSession'; jest.mock('next/link', () => jest.fn( ( @@ -13,19 +13,19 @@ jest.mock('next/link', () => jest.fn( ), )); -jest.mock('@/hooks/useSession', () => ({ +jest.mock('@auth0/auth0-react', () => ({ __esModule: true, - ...jest.requireActual('@/hooks/useSession'), + ...jest.requireActual('@auth0/auth0-react'), })); describe('LogoutPage', () => { - let mockRevoke: jest.Mock; + let mockLogout: jest.Mock; beforeEach(() => { - mockRevoke = jest.fn(); - jest.spyOn(sessionHook, 'default').mockReturnValue({ - revoke: mockRevoke as Function, - } as sessionHook.SessionReturn); + mockLogout = jest.fn(); + jest.spyOn(auth0, 'useAuth0').mockReturnValue({ + logout: mockLogout as Function, + } as auth0.Auth0ContextInterface); }); it('matches snapshot', () => { @@ -33,9 +33,13 @@ describe('LogoutPage', () => { expect(container).toMatchSnapshot(); }); - it('sets empty accessToken', () => { + it('calls logout', () => { render(); - expect(mockRevoke).toBeCalled(); + expect(mockLogout).toBeCalledWith({ + logoutParams: { + returnTo: 'http://localhost/user/login', + }, + }); }); }); diff --git a/src/__tests__/components/ProfileDropdown.test.tsx b/src/__tests__/components/ProfileDropdown.test.tsx index bd569008..41622719 100644 --- a/src/__tests__/components/ProfileDropdown.test.tsx +++ b/src/__tests__/components/ProfileDropdown.test.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react'; import ProfileDropdown from '@/components/ProfileDropdown'; import * as sessionHook from '@/hooks/useSession'; -import { User } from '@/types/user'; +import type { User } from '@auth0/auth0-react'; jest.mock('@/hooks/useSession', () => ({ __esModule: true, @@ -19,7 +19,7 @@ describe('ProfileDropdown', () => { jest.spyOn(sessionHook, 'default').mockReturnValue({ user: { name: '', - image: '', + picture: '', email: '', } as User, } as sessionHook.SessionReturn); @@ -34,7 +34,7 @@ describe('ProfileDropdown', () => { jest.spyOn(sessionHook, 'default').mockReturnValue({ user: { name: 'Maffin IO', - image: 'https://example.com', + picture: 'https://example.com', email: 'iomaffin@gmail.com', } as User, } as sessionHook.SessionReturn); diff --git a/src/__tests__/hooks/useGapiClient.test.ts b/src/__tests__/hooks/useGapiClient.test.ts index 60969353..0a49785f 100644 --- a/src/__tests__/hooks/useGapiClient.test.ts +++ b/src/__tests__/hooks/useGapiClient.test.ts @@ -3,7 +3,6 @@ import { act, renderHook } from '@testing-library/react'; import useGapiClient from '@/hooks/useGapiClient'; import * as sessionHook from '@/hooks/useSession'; import * as helpers_env from '@/helpers/env'; -import type { Credentials } from '@/types/user'; jest.mock('@/hooks/useSession', () => ({ __esModule: true, @@ -22,8 +21,7 @@ describe('useGapiClient', () => { jest.spyOn(helpers_env, 'isStaging').mockReturnValue(false); jest.spyOn(sessionHook, 'default').mockReturnValue({ - session: undefined, - setCredentials: jest.fn() as Function, + accessToken: '', } as sessionHook.SessionReturn); }); @@ -118,8 +116,7 @@ describe('useGapiClient', () => { it('returns true when script onload finished and session and loads needed libraries', async () => { jest.spyOn(sessionHook, 'default').mockReturnValue( { - session: { access_token: 'access_token' } as Credentials, - setCredentials: jest.fn() as Function, + accessToken: 'access_token', } as sessionHook.SessionReturn, ); const mockGapiClientLoad: jest.MockedFunction = jest.fn(); diff --git a/src/__tests__/hooks/useSession.test.ts b/src/__tests__/hooks/useSession.test.ts index fe9b6bda..229894c7 100644 --- a/src/__tests__/hooks/useSession.test.ts +++ b/src/__tests__/hooks/useSession.test.ts @@ -1,18 +1,14 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { DateTime } from 'luxon'; -import * as navigation from 'next/navigation'; -import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; +import { renderHook } from '@testing-library/react'; +import * as auth0 from '@auth0/auth0-react'; import useSession from '@/hooks/useSession'; -import * as Stocker from '@/lib/Stocker'; import * as helpers_env from '@/helpers/env'; -import type { Credentials } from '@/types/user'; jest.mock('next/navigation'); -jest.mock('@/lib/Stocker', () => ({ +jest.mock('@auth0/auth0-react', () => ({ __esModule: true, - ...jest.requireActual('@/lib/Stocker'), + ...jest.requireActual('@auth0/auth0-react'), })); jest.mock('@/helpers/env', () => ({ @@ -20,159 +16,50 @@ jest.mock('@/helpers/env', () => ({ isStaging: () => false, })); -const SESSION: Credentials = { - access_token: 'at', - refresh_token: 'rt', - expiry_date: 1706841538379, - id_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImlvbWFmZmluQGdtYWlsLmNvbSIsInBpY3R1cmUiOiJwaWN0dXJlIiwibmFtZSI6Ik1hZmZpbiBJTyIsImlhdCI6MTUxNjIzOTAyMn0.rxfAOUMY0t4AmKs_Xb7gJFwOsUSwfwk7aLDaCNk-tIk', -}; - describe('useSession', () => { - let mockRouterPush: jest.Mock; - beforeEach(() => { - localStorage.removeItem('session'); - - // 2023-01-01 - jest.spyOn(DateTime, 'now').mockReturnValue(DateTime.fromMillis(1672531200000) as DateTime); jest.spyOn(helpers_env, 'isStaging').mockReturnValue(false); - - mockRouterPush = jest.fn(); - jest.spyOn(navigation, 'useRouter').mockImplementation(() => ({ - push: mockRouterPush as AppRouterInstance['push'], - } as AppRouterInstance)); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.useRealTimers(); + jest.spyOn(auth0, 'useAuth0').mockReturnValue({ + isAuthenticated: false, + } as auth0.Auth0ContextInterface); }); - it('loads session from localStorage', () => { - localStorage.setItem('session', JSON.stringify(SESSION)); + it('returns emptyUser when no user', async () => { const { result } = renderHook(() => useSession()); - expect(result.current.session).toEqual(SESSION); + expect(result.current.accessToken).toEqual(''); expect(result.current.user).toEqual({ - email: 'iomaffin@gmail.com', - image: 'picture', - name: 'Maffin IO', + email: '', + picture: '', + name: '', }); }); - it('redirects to /user/login if no session in localStorage', () => { - renderHook(() => useSession()); - - expect(mockRouterPush).toBeCalledWith('/user/login'); - }); + it('sets accessToken and user when authenticated', async () => { + const user = { + email: 'iomaffin@gmail.com', + name: 'name', + accessToken: 'accessToken', + } as auth0.User; + jest.spyOn(auth0, 'useAuth0').mockReturnValue({ + isAuthenticated: true, + user, + } as auth0.Auth0ContextInterface); - it('updates localStorage whenever session data changes', async () => { - localStorage.setItem('session', JSON.stringify(SESSION)); const { result } = renderHook(() => useSession()); - const newCredentials = { - ...SESSION, - access_token: 'newat', - refresh_token: 'newrt', - id_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImlvbWFmZmluQGdtYWlsLmNvbSIsImlhdCI6MTUxNjIzOTAyMn0.EHc4Mf5W0zcRVN58IkYGCvG9HTdTz0Q-EVunQFt5Bbc', - }; - - act(() => result.current.setCredentials(newCredentials)); - - expect(result.current.session).toEqual(newCredentials); - expect(result.current.user).toEqual({ - email: 'iomaffin@gmail.com', - image: '', - name: '', - }); - expect(JSON.parse(localStorage.getItem('session') as string)).toEqual(newCredentials); + expect(result.current.accessToken).toEqual('accessToken'); + expect(result.current.user).toEqual(user); }); it('returns fake user when staging', async () => { jest.spyOn(helpers_env, 'isStaging').mockReturnValue(true); - localStorage.setItem('session', JSON.stringify(SESSION)); const { result } = renderHook(() => useSession()); expect(result.current.user).toEqual({ email: 'iomaffin@gmail.com', - image: '', + picture: '', name: 'Maffin', }); }); - - it('revoke removes localStorage session and sends to login', async () => { - localStorage.setItem('session', JSON.stringify(SESSION)); - const { result } = renderHook(() => useSession()); - - act(() => result.current.revoke()); - - expect(localStorage.getItem('session')).toEqual(null); - expect(mockRouterPush).toBeCalledWith('/user/login'); - }); - - it('refreshes access token when expired', async () => { - localStorage.setItem( - 'session', - JSON.stringify({ - ...SESSION, - expiry_date: 1672530200000, // Before DateTime.now - }), - ); - const newCredentials = { - ...SESSION, - access_token: 'newat', - refresh_token: 'newrt', - id_token: 'id_token', - }; - const mockRefresh = jest.spyOn(Stocker, 'refresh').mockResolvedValue(newCredentials); - - const { result } = renderHook(() => useSession()); - - await waitFor(() => expect(mockRefresh).toBeCalledWith('rt')); - // Make sure newCredentials are set but id_token is kept from previous values. - expect(result.current.session).toEqual({ - ...newCredentials, - id_token: SESSION.id_token, - }); - }); - - it('checks every 10s for access token expiring', async () => { - jest.useFakeTimers(); - localStorage.setItem( - 'session', - JSON.stringify({ - ...SESSION, - expiry_date: 1672531220000, // 20s after now - }), - ); - const newCredentials = { - ...SESSION, - access_token: 'newat', - refresh_token: 'newrt', - }; - const mockRefresh = jest.spyOn(Stocker, 'refresh').mockResolvedValue(newCredentials); - - const { result } = renderHook(() => useSession()); - - expect(mockRefresh).not.toBeCalled(); - await waitFor(() => expect(result.current.session).toEqual({ - ...SESSION, - expiry_date: 1672531220000, // 20s after now - })); - - act(() => { - jest.runOnlyPendingTimers(); - }); - - act(() => { - // Advance DateTime.now 20 seconds so expiry date check changes to expired - jest.spyOn(DateTime, 'now').mockReturnValue( - DateTime.fromMillis(1672531220001) as DateTime, - ); - jest.runOnlyPendingTimers(); - }); - - await waitFor(() => expect(mockRefresh).toBeCalledTimes(1)); - await waitFor(() => expect(result.current.session).toEqual(newCredentials)); - }); }); diff --git a/src/__tests__/layout/__snapshots__/LeftSideBar.test.tsx.snap b/src/__tests__/layout/__snapshots__/LeftSideBar.test.tsx.snap index 50789da6..138ced78 100644 --- a/src/__tests__/layout/__snapshots__/LeftSideBar.test.tsx.snap +++ b/src/__tests__/layout/__snapshots__/LeftSideBar.test.tsx.snap @@ -10,7 +10,7 @@ exports[`LeftSidebar renders as expected 1`] = ` href="/" > logo { - describe('authorize', () => { - it('passes code token and returns credentials', async () => { - jest.spyOn(API, 'get').mockResolvedValue({ - access_token: 'at', - }); - - const credentials = await authorize('code_token'); - - expect(API.get).toBeCalledWith( - 'stocker', - '/user/authorize', - { - queryStringParameters: { code: 'code_token' }, - }, - ); - expect(credentials).toEqual({ access_token: 'at' }); - }); - }); - - describe('refresh', () => { - it('passes refresh token and returns credentials', async () => { - jest.spyOn(API, 'get').mockResolvedValue({ - access_token: 'at', - }); - - const credentials = await refresh('refresh_token'); - - expect(API.get).toBeCalledWith( - 'stocker', - '/user/refresh', - { - queryStringParameters: { refresh_token: 'refresh_token' }, - }, - ); - expect(credentials).toEqual({ access_token: 'at' }); - }); - }); -}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 74a71fb5..fcb60ec6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,37 +1,39 @@ -'use client'; - import React from 'react'; import { Settings } from 'luxon'; import Script from 'next/script'; -import { Auth0Provider } from '@auth0/auth0-react'; import '@/css/globals.css'; +import Auth0Provider from '@/lib/auth0-provider'; +import { isProd } from '@/helpers/env'; Settings.throwOnInvalid = true; -// export const metadata = { -// title: 'maffin.io', -// description: 'Personal finance made easy', -// icons: { -// icon: [ -// { -// url: '/favicon/favicon-32x32.png', -// type: 'image/png', -// }, -// { -// url: '/favicon/favicon-16x16.png', -// sizes: '16x16', -// type: 'image/png', -// }, -// { -// url: '/favicon/favicon-32x32.png', -// sizes: '32x32', -// type: 'image/png', -// }, -// ], -// apple: '/favicon/apple-touch-icon.png', -// }, -// }; +export const metadata = { + title: { + template: '%s | Maffin', + default: 'Maffin', + }, + description: 'Personal finance made easy', + icons: { + icon: [ + { + url: '/favicon/favicon-32x32.png', + type: 'image/png', + }, + { + url: '/favicon/favicon-16x16.png', + sizes: '16x16', + type: 'image/png', + }, + { + url: '/favicon/favicon-32x32.png', + sizes: '32x32', + type: 'image/png', + }, + ], + apple: '/favicon/apple-touch-icon.png', + }, +}; export default function RootLayout({ children, @@ -41,11 +43,12 @@ export default function RootLayout({ {children} diff --git a/src/app/user/login/page.tsx b/src/app/user/login/page.tsx index febf1fa8..8d0954fa 100644 --- a/src/app/user/login/page.tsx +++ b/src/app/user/login/page.tsx @@ -4,54 +4,25 @@ import React from 'react'; import { useRouter } from 'next/navigation'; import { useAuth0 } from '@auth0/auth0-react'; -import Loading from '@/components/Loading'; import { isStaging } from '@/helpers/env'; -import { authorize } from '@/lib/Stocker'; -import useSession from '@/hooks/useSession'; export default function LoginPage(): JSX.Element { - const { setCredentials } = useSession(); const router = useRouter(); - const { loginWithRedirect, isAuthenticated } = useAuth0(); - const [ - codeClient, - setCodeClient, - ] = React.useState(null); + const { isAuthenticated, loginWithPopup } = useAuth0(); React.useEffect(() => { - setCodeClient(window.google.accounts?.oauth2.initCodeClient({ - client_id: '123339406534-gnk10bh5hqo87qlla8e9gmol1j961rtg.apps.googleusercontent.com', - scope: 'https://www.googleapis.com/auth/drive.file', - ux_mode: 'popup', - callback: async (response) => { - const credentials = await authorize(response.code); - setCredentials(credentials); - router.push('/dashboard/accounts'); - }, - })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (codeClient === null) { - return ( - - ); - } - - console.log(isAuthenticated); - // if (isStaging() || isAuthenticated) { - // router.push('/dashboard/accounts'); - // } + if (isStaging() || isAuthenticated) { + router.push('/dashboard/accounts'); + } + }, [isAuthenticated, router]); return (