Merge pull request #256 from akhilmhdh/feat/react-query

feat(ui): added new auth guard with react-query and axios
This commit is contained in:
BlackMagiq
2023-01-29 12:21:08 +07:00
committed by GitHub
16 changed files with 234 additions and 30 deletions

View File

@@ -46,11 +46,7 @@ services:
context: ./frontend context: ./frontend
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
volumes: volumes:
- ./frontend/src/pages:/app/src/pages - ./frontend/src:/app/src/ # mounted whole src to avoid missing reload on new files
- ./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/public:/app/public - ./frontend/public:/app/public
- ./frontend/next-i18next.config.js:/app/next-i18next.config.js - ./frontend/next-i18next.config.js:/app/next-i18next.config.js
env_file: .env env_file: .env

View File

@@ -28,6 +28,7 @@
"@reduxjs/toolkit": "^1.8.3", "@reduxjs/toolkit": "^1.8.3",
"@stripe/react-stripe-js": "^1.10.0", "@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.46.0", "@stripe/stripe-js": "^1.46.0",
"@tanstack/react-query": "^4.23.0",
"add": "^2.0.6", "add": "^2.0.6",
"axios": "^0.27.2", "axios": "^0.27.2",
"axios-auth-refresh": "^3.3.3", "axios-auth-refresh": "^3.3.3",
@@ -6646,6 +6647,41 @@
"tailwindcss": ">=3.0.0 || insiders" "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": { "node_modules/@testing-library/dom": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz",
@@ -26993,6 +27029,20 @@
"postcss-selector-parser": "6.0.10" "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": { "@testing-library/dom": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.0.tgz",

View File

@@ -35,6 +35,7 @@
"@reduxjs/toolkit": "^1.8.3", "@reduxjs/toolkit": "^1.8.3",
"@stripe/react-stripe-js": "^1.10.0", "@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.46.0", "@stripe/stripe-js": "^1.46.0",
"@tanstack/react-query": "^4.23.0",
"add": "^2.0.6", "add": "^2.0.6",
"axios": "^0.27.2", "axios": "^0.27.2",
"axios-auth-refresh": "^3.3.3", "axios-auth-refresh": "^3.3.3",

View File

@@ -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 { export default class SecurityClient {
static #token = '';
static setToken(tokenStr: string) { static setToken(tokenStr: string) {
this.#token = tokenStr; setAuthToken(tokenStr);
} }
static async fetchCall(resource: RequestInfo, options?: RequestInit | undefined) { static async fetchCall(resource: RequestInfo, options?: RequestInit | undefined) {
const req = new Request(resource, options); const req = new Request(resource, options);
if (this.#token === '') { const token = getAuthToken();
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');
}
}
if (this.#token) { if (token) {
req.headers.set('Authorization', `Bearer ${this.#token}`); req.headers.set('Authorization', `Bearer ${token}`);
} }
return fetch(req); return fetch(req);

View File

@@ -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;
});

View File

@@ -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 (
<div className="flex items-center justify-center w-screen h-screen bg-bunker-800">
<img src="/images/loading/loading.gif" alt="loading animation" className="w-80" />
</div>
);
}
return children as JSX.Element;
};

View File

@@ -0,0 +1 @@
export { AuthProvider } from './AuthContext';

View File

@@ -0,0 +1 @@
export { useGetAuthToken } from './queries';

View File

@@ -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<GetAuthTokenAPI>('/api/v1/auth/token', undefined, {
withCredentials: true
});
return data;
};
export const useGetAuthToken = () =>
useQuery(authKeys.getAuthToken, fetchAuthToken, {
onSuccess: (data) => setAuthToken(data.token),
retry: 0
});

View File

@@ -0,0 +1,3 @@
export type GetAuthTokenAPI = {
token: string;
};

View File

@@ -0,0 +1 @@
export { useGetAuthToken } from './auth';

View File

@@ -1 +1,2 @@
export { usePopUp } from './usePopUp'; export { usePopUp } from './usePopUp';
export { useToggle } from './useToggle';

View File

@@ -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 }];
};

View File

@@ -4,12 +4,14 @@ import { AppProps } from 'next/app';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { appWithTranslation } from 'next-i18next'; import { appWithTranslation } from 'next-i18next';
import { config } from '@fortawesome/fontawesome-svg-core'; import { config } from '@fortawesome/fontawesome-svg-core';
import { QueryClientProvider } from '@tanstack/react-query';
import Layout from '@app/components/basic/Layout'; import Layout from '@app/components/basic/Layout';
import NotificationProvider from '@app/components/context/Notifications/NotificationProvider'; import NotificationProvider from '@app/components/context/Notifications/NotificationProvider';
import RouteGuard from '@app/components/RouteGuard';
import Telemetry from '@app/components/utilities/telemetry/Telemetry'; import Telemetry from '@app/components/utilities/telemetry/Telemetry';
import { publicPaths } from '@app/const'; import { publicPaths } from '@app/const';
import { AuthProvider } from '@app/context/AuthContext';
import { queryClient } from '@app/reactQuery';
import '@fortawesome/fontawesome-svg-core/styles.css'; import '@fortawesome/fontawesome-svg-core/styles.css';
import '../styles/globals.css'; import '../styles/globals.css';
@@ -54,17 +56,25 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element =>
publicPaths.includes(`/${appProps.router.pathname.split('/')[1]}`) || publicPaths.includes(`/${appProps.router.pathname.split('/')[1]}`) ||
!Component.requireAuth !Component.requireAuth
) { ) {
return <Component {...pageProps} />; return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</QueryClientProvider>
);
} }
return ( return (
<RouteGuard> <QueryClientProvider client={queryClient}>
<NotificationProvider> <AuthProvider>
<Layout> <NotificationProvider>
<Component {...pageProps} /> <Layout>
</Layout> <Component {...pageProps} />
</NotificationProvider> </Layout>
</RouteGuard> </NotificationProvider>
</AuthProvider>
</QueryClientProvider>
); );
}; };

View File

@@ -13,6 +13,7 @@ import InputField from '@app/components/basic/InputField';
import ListBox from '@app/components/basic/Listbox'; import ListBox from '@app/components/basic/Listbox';
import attemptLogin from '@app/components/utilities/attemptLogin'; import attemptLogin from '@app/components/utilities/attemptLogin';
import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps'; import { getTranslatedStaticProps } from '@app/components/utilities/withTranslateProps';
import { isLoggedIn } from '@app/reactQuery';
import getWorkspaces from './api/workspace/getWorkspaces'; import getWorkspaces from './api/workspace/getWorkspaces';
@@ -31,6 +32,7 @@ export default function Login() {
}; };
useEffect(() => { useEffect(() => {
// TODO(akhilmhdh): workspace will be controlled by a workspace context
const redirectToDashboard = async () => { const redirectToDashboard = async () => {
let userWorkspace; let userWorkspace;
try { try {
@@ -41,7 +43,9 @@ export default function Login() {
console.log('Error - Not logged in yet'); console.log('Error - Not logged in yet');
} }
}; };
redirectToDashboard(); if (isLoggedIn()) {
redirectToDashboard();
}
}, []); }, []);
/** /**

View File

@@ -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());