diff --git a/src/.env b/src/.env index 93fa24a..9eeb008 100644 --- a/src/.env +++ b/src/.env @@ -1 +1 @@ -VITE_API_URL=http://localhost:8000 \ No newline at end of file +VITE_API_URL=http://127.0.0.1:8000 \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 0875ba0..e3614bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,24 +3,30 @@ import "./App.css"; import router from "./router/router"; import "react-toastify/dist/ReactToastify.css"; import { ToastContainer } from "react-toastify"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { AuthProvider } from "./context/AuthContext"; + +const queryClient = new QueryClient(); function App() { return ( - <> - - - + + + + + + ); } diff --git a/src/api/authApi.ts b/src/api/authApi.ts new file mode 100644 index 0000000..1f24552 --- /dev/null +++ b/src/api/authApi.ts @@ -0,0 +1,19 @@ +import type { + LoginPayload, + LoginResponse, + MeResponse, + User, +} from "../components/shared/types/AuthTypes"; +import axiosInstance from "./axiosInstance"; + +export const loginRequest = async ( + data: LoginPayload +): Promise => { + const res = await axiosInstance.post("/api/auth/login", data); + return res.data; +}; + +export const fetchMe = async (): Promise => { + const res = await axiosInstance.get("/api/auth/me"); + return res.data.user; +}; diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index 0981bf0..686a88b 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -1,7 +1,7 @@ import axios from "axios"; const axiosInstance = axios.create({ - baseURL: import.meta.env.VITE_API_URL, + baseURL: "http://127.0.0.1:8000", timeout: 30 * 1000, }); diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 3c860f4..ea23ba5 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -7,11 +7,16 @@ import Typography from "@mui/material/Typography"; import Menu from "@mui/material/Menu"; import MenuIcon from "@mui/icons-material/Menu"; import Container from "../../components/shared/Container"; -import StarIcon from '@mui/icons-material/Star'; +import StarIcon from "@mui/icons-material/Star"; import Button from "@mui/material/Button"; import MenuItem from "@mui/material/MenuItem"; +import { useAuth } from "../../context/AuthContext"; +import { useNavigate } from "react-router-dom"; function Header() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [anchorElNav, setAnchorElNav] = React.useState( null ); @@ -24,11 +29,13 @@ function Header() { setAnchorElNav(null); }; + const goToAdmin = () => navigate("/admin"); + return ( - + - + - - Categories - - - Profile - - - Create - + {!user && ( + { + handleCloseNavMenu(); + navigate("/login"); + }} + > + Login + + )} + {user && ( + + { + handleCloseNavMenu(); + navigate("/profile"); + }} + > + Profile + + {user.type === "admin" && ( + { + handleCloseNavMenu(); + goToAdmin(); + }} + > + + Admin Dashboard + + + )} + + )} @@ -91,7 +117,7 @@ function Header() { variant="h5" noWrap component="a" - href="#home" + href="/" sx={{ mr: 2, display: { xs: "flex", md: "none" }, @@ -107,15 +133,33 @@ function Header() { - - - + {!user && ( + + )} + + {user && ( + <> + + {user.type === "admin" && ( + + )} + + )} diff --git a/src/components/ProtectedRoute/ProtectedRoute.tsx b/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 0000000..1b4fcbc --- /dev/null +++ b/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { useAuth } from "../../context/AuthContext"; +import Unauthorized from "../../pages/Unauthorized/Unauthorized"; +import Container from "../shared/Container"; + +interface ProtectedRouteProps { + children: ReactNode; +} + +export const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const { user, isLoading } = useAuth(); + + if (isLoading) return Loading...; + + if (!user) return ; + + return <>{children}; +}; diff --git a/src/components/Question/Question.tsx b/src/components/Question/Question.tsx index 95b9b9d..ec13965 100644 --- a/src/components/Question/Question.tsx +++ b/src/components/Question/Question.tsx @@ -40,7 +40,7 @@ const Question = ({ question }: QuestionProps) => { Difficulty: {question.difficulty} - + {question.author} diff --git a/src/components/shared/types/AuthTypes.ts b/src/components/shared/types/AuthTypes.ts new file mode 100644 index 0000000..39fb308 --- /dev/null +++ b/src/components/shared/types/AuthTypes.ts @@ -0,0 +1,24 @@ +export interface User { + id: number; + username: string; + email: string; + email_verified_at: string | null; + type: string; + created_at: string; + updated_at: string; +} + +export interface LoginResponse { + access_token: string; + token_type: string; + user: User; +} + +export interface LoginPayload { + email: string; + password: string; +} + +export interface MeResponse { + user: User; +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..79a330b --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,55 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { useCurrentUser } from "../hooks/useCurrentUser"; +import { useLogin } from "../hooks/useLogin"; +import type { LoginPayload, User } from "../components/shared/types/AuthTypes"; +import { createContext, useContext, type ReactNode } from "react"; + +interface AuthContextType { + user: User | null; + login: (data: LoginPayload) => Promise; + logout: () => void; + isLoading: boolean; +} + +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error("useAuth must be used within AuthProvider"); + return context; +}; + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const queryClient = useQueryClient(); + const { data: user, isLoading } = useCurrentUser(); + const loginMutation = useLogin(); + + const login = async (payload: LoginPayload) => { + await loginMutation.mutateAsync(payload, { + onSuccess: (data) => { + localStorage.setItem("access_token", data.access_token); + queryClient.setQueryData(["me"], data.user); + toast.success(`Welcome ${data.user.username}`); + }, + onError: (err: any) => { + toast.error(err.response?.data?.message || "Login failed"); + }, + }); + }; + + const logout = () => { + localStorage.removeItem("access_token"); + queryClient.removeQueries({ queryKey: ["me"] }); + toast.info("Logged out"); + window.location.href = "/login"; + }; + + return ( + + {children} + + ); +}; diff --git a/src/hooks/useCurrentUser.ts b/src/hooks/useCurrentUser.ts new file mode 100644 index 0000000..42c056b --- /dev/null +++ b/src/hooks/useCurrentUser.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import type { User } from "../components/shared/types/AuthTypes"; +import { fetchMe } from "../api/authApi"; + +export const useCurrentUser = () => + useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + enabled: !!localStorage.getItem("access_token"), + retry: false, + staleTime: 5 * 60 * 1000, + }); diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts new file mode 100644 index 0000000..ddb965f --- /dev/null +++ b/src/hooks/useLogin.ts @@ -0,0 +1,11 @@ +import { useMutation } from "@tanstack/react-query"; +import type { + LoginPayload, + LoginResponse, +} from "../components/shared/types/AuthTypes"; +import { loginRequest } from "../api/authApi"; + +export const useLogin = () => + useMutation({ + mutationFn: loginRequest, + }); diff --git a/src/pages/LoginPage/LoginPage.tsx b/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..7656dce --- /dev/null +++ b/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "../../context/AuthContext"; +import Container from "../../components/shared/Container"; +import { Box, Button, Paper, TextField, Typography } from "@mui/material"; +import { useNavigate } from "react-router-dom"; + +const LoginPage = () => { + const { login, user } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const navigate = useNavigate(); + + useEffect(() => { + if (user) { + navigate("/"); + } + }, [user, navigate]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await login({ email, password }); + }; + + return ( + + + + Login + +
+ setEmail(e.target.value)} + fullWidth + margin="normal" + required + /> + setPassword(e.target.value)} + fullWidth + margin="normal" + required + /> + + +
+
+ ); +}; + +export default LoginPage; diff --git a/src/pages/ProfilePage/Profilepage.tsx b/src/pages/ProfilePage/Profilepage.tsx new file mode 100644 index 0000000..deb835f --- /dev/null +++ b/src/pages/ProfilePage/Profilepage.tsx @@ -0,0 +1,43 @@ +import { Box, Button, Typography } from "@mui/material"; +import { useAuth } from "../../context/AuthContext"; +import Container from "../../components/shared/Container"; +import { useNavigate } from "react-router-dom"; + +const ProfilePage = () => { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + return ( + + + + Profile + + + Username: {user?.username} + + + E-mail: {user?.email} + + + Role: {user?.type} + + + {user?.type === "admin" && ( + + )} + + + ); +}; + +export default ProfilePage; diff --git a/src/pages/Unauthorized/Unauthorized.tsx b/src/pages/Unauthorized/Unauthorized.tsx new file mode 100644 index 0000000..b8d16d5 --- /dev/null +++ b/src/pages/Unauthorized/Unauthorized.tsx @@ -0,0 +1,18 @@ +import { Box, Typography } from "@mui/material"; + +export default function Unauthorized() { + return ( + + + 401 + + Unauthorized + You do not have access to this page. + + ); +} diff --git a/src/router/router.tsx b/src/router/router.tsx index c2201c4..ff5b336 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -1,23 +1,33 @@ import { createBrowserRouter } from "react-router-dom"; import MainLayout from "../layouts/MainLayout/MainLayout"; import IndexPage from "../pages/IndexPage/IndexPage"; -import QuestionsPage from "../pages/QuestionsPage/QuestionsPage"; +import LoginPage from "../pages/LoginPage/LoginPage"; +import ProfilePage from "../pages/ProfilePage/Profilepage"; +import { ProtectedRoute } from "../components/ProtectedRoute/ProtectedRoute"; -const router = createBrowserRouter ([ - { - path:'/', - element: , - children: [ - { - index:true, - element: - }, - { - path: "/questions", - element: - } - ] - } -]) +const router = createBrowserRouter([ + { + path: "/", + element: , + children: [ + { + index: true, + element: , + }, + { + path: "/login", + element: , + }, + { + path: "/profile", + element: ( + + + + ), + }, + ], + }, +]); -export default router; \ No newline at end of file +export default router;