From a6387e75521502a308c9ad7331ae9349aa157f29 Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Thu, 26 Jan 2023 00:14:01 +0530 Subject: [PATCH] feat(ui): added new auth guard with react-query and axios --- docker-compose.dev.yml | 6 +-- frontend/package-lock.json | 50 +++++++++++++++++++ frontend/package.json | 1 + .../components/utilities/SecurityClient.ts | 21 +++----- frontend/src/config/request.ts | 19 +++++++ .../src/context/AuthContext/AuthContext.tsx | 49 ++++++++++++++++++ frontend/src/context/AuthContext/index.tsx | 1 + frontend/src/hooks/api/auth/index.tsx | 1 + frontend/src/hooks/api/auth/queries.tsx | 26 ++++++++++ frontend/src/hooks/api/auth/types.ts | 3 ++ frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useToggle.tsx | 30 +++++++++++ frontend/src/pages/_app.tsx | 28 +++++++---- frontend/src/pages/login.tsx | 6 ++- frontend/src/reactQuery.ts | 21 ++++++++ 16 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 frontend/src/config/request.ts create mode 100644 frontend/src/context/AuthContext/AuthContext.tsx create mode 100644 frontend/src/context/AuthContext/index.tsx create mode 100644 frontend/src/hooks/api/auth/index.tsx create mode 100644 frontend/src/hooks/api/auth/queries.tsx create mode 100644 frontend/src/hooks/api/auth/types.ts create mode 100644 frontend/src/hooks/api/index.tsx create mode 100644 frontend/src/hooks/useToggle.tsx create mode 100644 frontend/src/reactQuery.ts diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 564fdb0c4..e7bef8824 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -46,11 +46,7 @@ services: context: ./frontend dockerfile: Dockerfile.dev volumes: - - ./frontend/src/pages:/app/src/pages - - ./frontend/src/components:/app/src/components - - ./frontend/src/ee:/app/src/ee - - ./frontend/src/locales:/app/src/locales - - ./frontend/src/styles:/app/src/styles + - ./frontend/src:/app/src/ # mounted whole src to avoid missing reload on new files - ./frontend/public:/app/public - ./frontend/next-i18next.config.js:/app/next-i18next.config.js env_file: .env diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1348fbdc3..84e34b8ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "@reduxjs/toolkit": "^1.8.3", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.46.0", + "@tanstack/react-query": "^4.23.0", "add": "^2.0.6", "axios": "^0.27.2", "axios-auth-refresh": "^3.3.3", @@ -6630,6 +6631,41 @@ "tailwindcss": ">=3.0.0 || insiders" } }, + "node_modules/@tanstack/query-core": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.22.4.tgz", + "integrity": "sha512-t79CMwlbBnj+yL82tEcmRN93bL4U3pae2ota4t5NN2z3cIeWw74pzdWrKRwOfTvLcd+b30tC+ciDlfYOKFPGUw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.23.0.tgz", + "integrity": "sha512-cfQsrecZQjYYueiow4WcK8ItokXJnv+b2OrK8Lf5kF7lM9uCo1ilyygFB8wo4MfxchUBVM6Cs8wq4Ed7fouwkA==", + "dependencies": { + "@tanstack/query-core": "4.22.4", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@testing-library/dom": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz", @@ -26887,6 +26923,20 @@ "postcss-selector-parser": "6.0.10" } }, + "@tanstack/query-core": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.22.4.tgz", + "integrity": "sha512-t79CMwlbBnj+yL82tEcmRN93bL4U3pae2ota4t5NN2z3cIeWw74pzdWrKRwOfTvLcd+b30tC+ciDlfYOKFPGUw==" + }, + "@tanstack/react-query": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.23.0.tgz", + "integrity": "sha512-cfQsrecZQjYYueiow4WcK8ItokXJnv+b2OrK8Lf5kF7lM9uCo1ilyygFB8wo4MfxchUBVM6Cs8wq4Ed7fouwkA==", + "requires": { + "@tanstack/query-core": "4.22.4", + "use-sync-external-store": "^1.2.0" + } + }, "@testing-library/dom": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 70e2f2358..3f3711c63 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "@reduxjs/toolkit": "^1.8.3", "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.46.0", + "@tanstack/react-query": "^4.23.0", "add": "^2.0.6", "axios": "^0.27.2", "axios-auth-refresh": "^3.3.3", diff --git a/frontend/src/components/utilities/SecurityClient.ts b/frontend/src/components/utilities/SecurityClient.ts index 57e35d6b2..fa1ec0bb0 100644 --- a/frontend/src/components/utilities/SecurityClient.ts +++ b/frontend/src/components/utilities/SecurityClient.ts @@ -1,27 +1,18 @@ -import token from '@app/pages/api/auth/Token'; +import { getAuthToken, setAuthToken } from '@app/reactQuery'; +// depreciated: go for apiRequest module in config/api export default class SecurityClient { - static #token = ''; - static setToken(tokenStr: string) { - this.#token = tokenStr; + setAuthToken(tokenStr); } static async fetchCall(resource: RequestInfo, options?: RequestInit | undefined) { const req = new Request(resource, options); - if (this.#token === '') { - try { - // TODO: This should be moved to a context to do it only once when app loads - // this try catch saves route guard from a stuck state - this.setToken(await token()); - } catch (error) { - console.error('Unauthorized access'); - } - } + const token = getAuthToken(); - if (this.#token) { - req.headers.set('Authorization', `Bearer ${this.#token}`); + if (token) { + req.headers.set('Authorization', `Bearer ${token}`); } return fetch(req); diff --git a/frontend/src/config/request.ts b/frontend/src/config/request.ts new file mode 100644 index 000000000..0e04789b2 --- /dev/null +++ b/frontend/src/config/request.ts @@ -0,0 +1,19 @@ +import axios from 'axios'; + +import { getAuthToken } from '@app/reactQuery'; + +export const apiRequest = axios.create({ + baseURL: '/', + headers: { + 'Content-Type': 'application/json' + } +}); + +apiRequest.interceptors.request.use((config) => { + const token = getAuthToken(); + if (token && config.headers) { + // eslint-disable-next-line no-param-reassign + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); diff --git a/frontend/src/context/AuthContext/AuthContext.tsx b/frontend/src/context/AuthContext/AuthContext.tsx new file mode 100644 index 000000000..c064852bd --- /dev/null +++ b/frontend/src/context/AuthContext/AuthContext.tsx @@ -0,0 +1,49 @@ +import { ReactNode, useEffect } from 'react'; +import { useRouter } from 'next/router'; + +import { publicPaths } from '@app/const'; +import { useToggle } from '@app/hooks'; +import { useGetAuthToken } from '@app/hooks/api'; +import { isLoggedIn } from '@app/reactQuery'; + +type Props = { + children: ReactNode; +}; + +// TODO(akhilmhdh): Using react-simple-animate from hard dom offloading +// smoother dom offloading needs to be done + +// Authentication controller +// Does route checking +// Provide a context for whole app to notify user is authorized or not +export const AuthProvider = ({ children }: Props): JSX.Element => { + const { isLoading } = useGetAuthToken(); + const { pathname, push } = useRouter(); + const [isReady, setIsReady] = useToggle(false); + + useEffect(() => { + // check if loading of auth is done + if (!isLoading) { + // not a public path and not authenticated kick to login page + if (!publicPaths.includes(pathname) && !isLoggedIn()) { + push('/login').then(() => { + setIsReady.on(); + }); + } else { + // else good to go + setIsReady.on(); + } + } + }, [pathname, isLoading]); + + // wait for app to load the auth state + if (isLoading || !isReady) { + return ( +
+ loading animation +
+ ); + } + + return children as JSX.Element; +}; diff --git a/frontend/src/context/AuthContext/index.tsx b/frontend/src/context/AuthContext/index.tsx new file mode 100644 index 000000000..e385cc124 --- /dev/null +++ b/frontend/src/context/AuthContext/index.tsx @@ -0,0 +1 @@ +export { AuthProvider } from './AuthContext'; diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx new file mode 100644 index 000000000..5b7837303 --- /dev/null +++ b/frontend/src/hooks/api/auth/index.tsx @@ -0,0 +1 @@ +export { useGetAuthToken } from './queries'; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx new file mode 100644 index 000000000..ceae44ed8 --- /dev/null +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query'; + +import { apiRequest } from '@app/config/request'; +import { setAuthToken } from '@app/reactQuery'; + +import { GetAuthTokenAPI } from './types'; + +const authKeys = { + getAuthToken: ['token'] as const +}; + +// Refresh token is set as cookie when logged in +// Using that we fetch the auth bearer token needed for auth calls +const fetchAuthToken = async () => { + const { data } = await apiRequest.post('/api/v1/auth/token', undefined, { + withCredentials: true + }); + + return data; +}; + +export const useGetAuthToken = () => + useQuery(authKeys.getAuthToken, fetchAuthToken, { + onSuccess: (data) => setAuthToken(data.token), + retry: 0 + }); diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts new file mode 100644 index 000000000..998df8a5d --- /dev/null +++ b/frontend/src/hooks/api/auth/types.ts @@ -0,0 +1,3 @@ +export type GetAuthTokenAPI = { + token: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx new file mode 100644 index 000000000..9df70dcf0 --- /dev/null +++ b/frontend/src/hooks/api/index.tsx @@ -0,0 +1 @@ +export { useGetAuthToken } from './auth'; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index dcea2eb7c..c34dc4e41 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1 +1,2 @@ export { usePopUp } from './usePopUp'; +export { useToggle } from './useToggle'; diff --git a/frontend/src/hooks/useToggle.tsx b/frontend/src/hooks/useToggle.tsx new file mode 100644 index 000000000..a9321ebdf --- /dev/null +++ b/frontend/src/hooks/useToggle.tsx @@ -0,0 +1,30 @@ +import { useCallback, useState } from 'react'; + +type VoidFn = () => void; + +type UseToggleReturn = [ + boolean, + { + on: VoidFn; + off: VoidFn; + toggle: VoidFn; + } +]; + +export const useToggle = (initialState = false): UseToggleReturn => { + const [value, setValue] = useState(initialState); + + const on = useCallback(() => { + setValue(true); + }, []); + + const off = useCallback(() => { + setValue(false); + }, []); + + const toggle = useCallback((isOpen?: boolean) => { + setValue((prev) => (typeof isOpen === 'boolean' ? isOpen : !prev)); + }, []); + + return [value, { on, off, toggle }]; +}; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 4e22c943f..5bfaaa1e3 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -4,12 +4,14 @@ import { AppProps } from 'next/app'; import { useRouter } from 'next/router'; import { appWithTranslation } from 'next-i18next'; import { config } from '@fortawesome/fontawesome-svg-core'; +import { QueryClientProvider } from '@tanstack/react-query'; import Layout from '@app/components/basic/Layout'; import NotificationProvider from '@app/components/context/Notifications/NotificationProvider'; -import RouteGuard from '@app/components/RouteGuard'; import Telemetry from '@app/components/utilities/telemetry/Telemetry'; import { publicPaths } from '@app/const'; +import { AuthProvider } from '@app/context/AuthContext'; +import { queryClient } from '@app/reactQuery'; import '@fortawesome/fontawesome-svg-core/styles.css'; import '../styles/globals.css'; @@ -54,17 +56,25 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element => publicPaths.includes(`/${appProps.router.pathname.split('/')[1]}`) || !Component.requireAuth ) { - return ; + return ( + + + + + + ); } return ( - - - - - - - + + + + + + + + + ); }; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 571a636f7..c32c4bd4f 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -13,6 +13,7 @@ import InputField from '@app/components/basic/InputField'; import ListBox from '@app/components/basic/Listbox'; import attemptLogin from '@app/components/utilities/attemptLogin'; import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps'; +import { isLoggedIn } from '@app/reactQuery'; import getWorkspaces from './api/workspace/getWorkspaces'; @@ -31,6 +32,7 @@ export default function Login() { }; useEffect(() => { + // TODO(akhilmhdh): workspace will be controlled by a workspace context const redirectToDashboard = async () => { let userWorkspace; try { @@ -41,7 +43,9 @@ export default function Login() { console.log('Error - Not logged in yet'); } }; - redirectToDashboard(); + if (isLoggedIn()) { + redirectToDashboard(); + } }, []); /** diff --git a/frontend/src/reactQuery.ts b/frontend/src/reactQuery.ts new file mode 100644 index 000000000..c28dc39b1 --- /dev/null +++ b/frontend/src/reactQuery.ts @@ -0,0 +1,21 @@ +import { QueryClient } from '@tanstack/react-query'; + +// this is saved in react-query cache +export const AUTH_TOKEN_CACHE_KEY = ['infisical__auth-token']; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1 + } + } +}); + +// set token in memory cache +export const setAuthToken = (token: string) => + queryClient.setQueryData(AUTH_TOKEN_CACHE_KEY, token); + +export const getAuthToken = () => queryClient.getQueryData(AUTH_TOKEN_CACHE_KEY) as string; + +export const isLoggedIn = () => Boolean(getAuthToken());