diff --git a/src/frontend/src/components/authGuard/index.tsx b/src/frontend/src/components/authGuard/index.tsx new file mode 100644 index 000000000..25cc804ee --- /dev/null +++ b/src/frontend/src/components/authGuard/index.tsx @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import { Navigate } from "react-router-dom"; +import { AuthContext } from "../../contexts/authContext"; + +export const ProtectedRoute = ({ children }) => { + + const { isAuthenticated } = useContext(AuthContext); + + if (!isAuthenticated) { + return ; + } + + return children; + }; \ No newline at end of file diff --git a/src/frontend/src/components/headerComponent/index.tsx b/src/frontend/src/components/headerComponent/index.tsx index 2a5fef057..296740a91 100644 --- a/src/frontend/src/components/headerComponent/index.tsx +++ b/src/frontend/src/components/headerComponent/index.tsx @@ -1,6 +1,6 @@ import { useContext, useEffect, useState } from "react"; import { FaDiscord, FaGithub, FaTwitter } from "react-icons/fa"; -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import AlertDropdown from "../../alerts/alertDropDown"; import { USER_PROJECTS_HEADER } from "../../constants/constants"; import { alertContext } from "../../contexts/alertContext"; @@ -11,12 +11,15 @@ import IconComponent from "../genericIconComponent"; import { Button } from "../ui/button"; import { Separator } from "../ui/separator"; import MenuBar from "./components/menuBar"; +import { AuthContext } from "../../contexts/authContext"; export default function Header() { const { flows, tabId } = useContext(TabsContext); const { dark, setDark } = useContext(darkContext); const { notificationCenter } = useContext(alertContext); const location = useLocation(); + const { logout } = useContext(AuthContext); + const navigate = useNavigate(); const [stars, setStars] = useState(null); @@ -34,7 +37,13 @@ export default function Header() { ⛓️ - {flows.findIndex((f) => tabId === f.id) !== -1 && tabId !== "" && ( diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 33aeb895a..8831927bb 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -507,6 +507,7 @@ export const URL_EXCLUDED_FROM_ERROR_RETRIES = [ "/api/v1/validate/code", "/api/v1/custom_component", "/api/v1/validate/prompt", + "http://localhost:7860/login" ]; export const CONTROL_INPUT_STATE = { password: "", diff --git a/src/frontend/src/contexts/authContext.tsx b/src/frontend/src/contexts/authContext.tsx index dc4ca52c9..823b0913a 100644 --- a/src/frontend/src/contexts/authContext.tsx +++ b/src/frontend/src/contexts/authContext.tsx @@ -1,9 +1,12 @@ import { createContext, useEffect, useState } from "react"; import { AuthContextType, userData } from "../types/contexts/auth"; +import { LoginType } from "../types/api"; +import { api } from "../controllers/API/api"; const initialValue: AuthContextType = { isAuthenticated: false, accessToken: null, + refreshToken: null, login: () => {}, logout: () => {}, refreshAccessToken: () => Promise.resolve(), @@ -11,10 +14,12 @@ const initialValue: AuthContextType = { setUserData: () => {}, }; -const AuthContext = createContext(initialValue); +export const AuthContext = createContext(initialValue); export function AuthProvider({ children }): React.ReactElement { const [accessToken, setAccessToken] = useState(null); + const [refreshToken, setRefreshToken] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); const [userData, setUserData] = useState(null); useEffect(() => { @@ -27,18 +32,23 @@ export function AuthProvider({ children }): React.ReactElement { function login(newAccessToken: string, refreshToken: string) { localStorage.setItem("access_token", newAccessToken); setAccessToken(newAccessToken); - // Store refreshToken if needed + + localStorage.setItem("refresh_token", refreshToken); + setRefreshToken(refreshToken); + + setIsAuthenticated(true); } function logout() { localStorage.removeItem("access_token"); - // Clear refreshToken if used + localStorage.removeItem("refresh_token"); setAccessToken(null); + setRefreshToken(null); + setIsAuthenticated(false); } async function refreshAccessToken(refreshToken: string) { try { - // Call your API to refresh the access token using the refresh token const response = await fetch("/api/refresh-token", { method: "POST", headers: { @@ -57,6 +67,7 @@ export function AuthProvider({ children }): React.ReactElement { logout(); } } + return ( // !! to convert string to boolean @@ -64,6 +75,7 @@ export function AuthProvider({ children }): React.ReactElement { value={{ isAuthenticated: !!accessToken, accessToken, + refreshToken, login, logout, refreshAccessToken, diff --git a/src/frontend/src/contexts/index.tsx b/src/frontend/src/contexts/index.tsx index f143df708..915f8bb8e 100644 --- a/src/frontend/src/contexts/index.tsx +++ b/src/frontend/src/contexts/index.tsx @@ -8,6 +8,7 @@ import { LocationProvider } from "./locationContext"; import { TabsProvider } from "./tabsContext"; import { TypesProvider } from "./typesContext"; import { UndoRedoProvider } from "./undoRedoContext"; +import { AuthProvider } from "./authContext"; export default function ContextWrapper({ children }: { children: ReactNode }) { //element to wrap all context @@ -18,6 +19,7 @@ export default function ContextWrapper({ children }: { children: ReactNode }) { + @@ -25,6 +27,7 @@ export default function ContextWrapper({ children }: { children: ReactNode }) { + diff --git a/src/frontend/src/controllers/API/api.tsx b/src/frontend/src/controllers/API/api.tsx index 6029d135e..3637754cb 100644 --- a/src/frontend/src/controllers/API/api.tsx +++ b/src/frontend/src/controllers/API/api.tsx @@ -1,6 +1,10 @@ import axios, { AxiosError, AxiosInstance } from "axios"; import { useContext, useEffect, useRef } from "react"; import { alertContext } from "../../contexts/alertContext"; +import { AuthContext } from "../../contexts/authContext"; +import { URL_EXCLUDED_FROM_ERROR_RETRIES } from "../../constants/constants"; +import { renewAccessToken } from "."; +import { useNavigate } from "react-router-dom"; // Create a new Axios instance const api: AxiosInstance = axios.create({ @@ -10,44 +14,80 @@ const api: AxiosInstance = axios.create({ function ApiInterceptor() { const retryCounts = useRef([]); const { setErrorData } = useContext(alertContext); + const { accessToken, refreshAccessToken, login, logout } = useContext(AuthContext); + const navigate = useNavigate(); useEffect(() => { const interceptor = api.interceptors.response.use( (response) => response, async (error: AxiosError) => { - // if (URL_EXCLUDED_FROM_ERROR_RETRIES.includes(error.config?.url)) { - // return Promise.reject(error); - // } - // let retryCount = 0; - // while (retryCount < 4) { - // await sleep(5000); // Sleep for 5 seconds - // retryCount++; - // try { - // const response = await axios.request(error.config); - // return response; - // } catch (error) { - // if (retryCount === 3) { - // setErrorData({ - // title: "There was an error on web connection, please: ", - // list: [ - // "Refresh the page", - // "Use a new flow tab", - // "Check if the backend is up", - // "Endpoint: " + error.config?.url, - // ], - // }); - // return Promise.reject(error); - // } - // } - // } + if (URL_EXCLUDED_FROM_ERROR_RETRIES.includes(error.config?.url)) { + return Promise.reject(error); + } + + if(error.response?.status === 401){ + const refreshToken = localStorage.getItem("refresh_token"); + if (refreshToken) { + const res = await renewAccessToken(refreshToken); + login(res.access_token, res.refresh_token) + try { + const response = await axios.request(error.config); + return response; + } catch (error) { + if(error.response?.status === 401){ + logout(); + navigate("/login"); + } + } + } + } + + else{ + let retryCount = 0; + while (retryCount < 4) { + await sleep(5000); // Sleep for 5 seconds + retryCount++; + try { + const response = await axios.request(error.config); + return response; + } catch (error) { + if (retryCount === 3) { + setErrorData({ + title: "There was an error on web connection, please: ", + list: [ + "Refresh the page", + "Use a new flow tab", + "Check if the backend is up", + "Endpoint: " + error.config?.url, + ], + }); + return Promise.reject(error); + } + } + } + } + } + ); + + // Request interceptor to add access token to every request + const requestInterceptor = api.interceptors.request.use( + (config) => { + if (accessToken) { + config.headers["Authorization"] = `Bearer ${accessToken}`; + } + return config; + }, + (error) => { + return Promise.reject(error); } ); return () => { - // Clean up the interceptor when the component unmounts + // Clean up the interceptors when the component unmounts api.interceptors.response.eject(interceptor); + api.interceptors.request.eject(requestInterceptor); }; - }, [retryCounts]); + }, [accessToken, setErrorData]); return null; } diff --git a/src/frontend/src/controllers/API/index.ts b/src/frontend/src/controllers/API/index.ts index 9491f8973..ac2e92696 100644 --- a/src/frontend/src/controllers/API/index.ts +++ b/src/frontend/src/controllers/API/index.ts @@ -1,7 +1,7 @@ import { AxiosResponse } from "axios"; import { ReactFlowJsonObject } from "reactflow"; import { api } from "../../controllers/API/api"; -import { APIObjectType, sendAllProps } from "../../types/api/index"; +import { APIObjectType, LoginAuthType, LoginType, sendAllProps } from "../../types/api/index"; import { FlowStyleType, FlowType } from "../../types/flow"; import { APIClassType, @@ -346,3 +346,43 @@ export async function postCustomComponent( ): Promise> { return await api.post(`/api/v1/custom_component`, { code }); } + +export async function onLogin( + user: LoginType +) { + try { + const response = await api.post( + "http://localhost:7860/login", + new URLSearchParams({ + username: user.username, + password: user.password, + }).toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + if (response.status === 200) { + const data = response.data; + return data; + } + } catch (error) { + console.log("Error:", error); + throw error; + } +} + +export async function renewAccessToken( + token: string +): Promise { + try { + return await api.post(`http://localhost:7860/refresh?token=${token}`); + } catch (error) { + console.log("Error:", error); + throw error; + } +} + + diff --git a/src/frontend/src/pages/loginPage/index.tsx b/src/frontend/src/pages/loginPage/index.tsx index fa953f6ca..2ded4539b 100644 --- a/src/frontend/src/pages/loginPage/index.tsx +++ b/src/frontend/src/pages/loginPage/index.tsx @@ -1,6 +1,6 @@ import * as Form from "@radix-ui/react-form"; -import { useState } from "react"; -import { Link } from "react-router-dom"; +import { useContext, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; import IconComponent from "../../components/genericIconComponent"; import InputComponent from "../../components/inputComponent"; import { Button } from "../../components/ui/button"; @@ -10,18 +10,43 @@ import { inputHandlerEventType, loginInputStateType, } from "../../types/components"; +import { onLogin } from "../../controllers/API"; +import { LoginType } from "../../types/api"; +import { AuthContext } from "../../contexts/authContext"; export default function LoginPage(): JSX.Element { const [inputState, setInputState] = useState(CONTROL_LOGIN_STATE); const { password, username } = inputState; + const { login } = useContext(AuthContext); + const navigate = useNavigate(); function handleInput({ target: { name, value }, }: inputHandlerEventType): void { setInputState((prev) => ({ ...prev, [name]: value })); } + + function signIn(){ + + const user: LoginType = { + username: username, + password: password + }; + + try{ + onLogin( + user + ).then((user) => { + login(user.access_token, user.refresh_token); + navigate("/"); + }); + } + catch(error){ + } + } + return ( { @@ -107,7 +132,9 @@ export default function LoginPage(): JSX.Element {
- +
diff --git a/src/frontend/src/routes.tsx b/src/frontend/src/routes.tsx index 8466f9b57..43a5bc32e 100644 --- a/src/frontend/src/routes.tsx +++ b/src/frontend/src/routes.tsx @@ -1,4 +1,5 @@ import { Route, Routes } from "react-router-dom"; +import { ProtectedRoute } from "./components/authGuard"; import AdminPage from "./pages/AdminPage"; import LoginAdminPage from "./pages/AdminPage/LoginPage"; import CommunityPage from "./pages/CommunityPage"; @@ -11,21 +12,56 @@ import SignUp from "./pages/signUpPage"; const Router = () => { return ( - } /> + + + + } + /> } /> - } /> + + + + } + /> - } /> + + + + } + /> } /> } /> } /> - } /> + + + + } + /> - }> + + + + } + > ); diff --git a/src/frontend/src/types/api/index.ts b/src/frontend/src/types/api/index.ts index 3fa848326..b355a71e4 100644 --- a/src/frontend/src/types/api/index.ts +++ b/src/frontend/src/types/api/index.ts @@ -62,3 +62,18 @@ export type UploadFileTypeAPI = { file_path: string; flowId: string; }; + +export type LoginType = { + grant_type?: string; + username: string; + password: string; + scrope?: string; + client_id?: string; + client_secret?: string; +}; + +export type LoginAuthType = { + access_token: string; + refresh_token: string; + token_type?: string; +}; diff --git a/src/frontend/src/types/contexts/auth.ts b/src/frontend/src/types/contexts/auth.ts index af037ecae..1ac47d6c2 100644 --- a/src/frontend/src/types/contexts/auth.ts +++ b/src/frontend/src/types/contexts/auth.ts @@ -1,6 +1,7 @@ export type AuthContextType = { isAuthenticated: boolean; accessToken: string | null; + refreshToken: string | null; login: (accessToken: string, refreshToken: string) => void; logout: () => void; refreshAccessToken: (refreshToken: string) => Promise;