login and logout

This commit is contained in:
David Katrinka 2025-11-23 18:52:26 +01:00
parent 4327a33837
commit b427c148af
15 changed files with 390 additions and 66 deletions

View File

@ -1 +1 @@
VITE_API_URL=http://localhost:8000 VITE_API_URL=http://127.0.0.1:8000

View File

@ -3,24 +3,30 @@ import "./App.css";
import router from "./router/router"; import router from "./router/router";
import "react-toastify/dist/ReactToastify.css"; import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./context/AuthContext";
const queryClient = new QueryClient();
function App() { function App() {
return ( return (
<> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <AuthProvider>
<ToastContainer <RouterProvider router={router} />
position="top-right" <ToastContainer
autoClose={3000} position="top-right"
hideProgressBar={false} autoClose={3000}
newestOnTop={false} hideProgressBar={false}
closeOnClick newestOnTop={false}
rtl={false} closeOnClick
pauseOnFocusLoss rtl={false}
draggable pauseOnFocusLoss
pauseOnHover draggable
theme="colored" pauseOnHover
/> theme="colored"
</> />
</AuthProvider>
</QueryClientProvider>
); );
} }

19
src/api/authApi.ts Normal file
View File

@ -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<LoginResponse> => {
const res = await axiosInstance.post<LoginResponse>("/api/auth/login", data);
return res.data;
};
export const fetchMe = async (): Promise<User> => {
const res = await axiosInstance.get<MeResponse>("/api/auth/me");
return res.data.user;
};

View File

@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: "http://127.0.0.1:8000",
timeout: 30 * 1000, timeout: 30 * 1000,
}); });

View File

@ -7,11 +7,16 @@ import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu"; import Menu from "@mui/material/Menu";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import Container from "../../components/shared/Container"; 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 Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { useAuth } from "../../context/AuthContext";
import { useNavigate } from "react-router-dom";
function Header() { function Header() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>( const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(
null null
); );
@ -24,11 +29,13 @@ function Header() {
setAnchorElNav(null); setAnchorElNav(null);
}; };
const goToAdmin = () => navigate("/admin");
return ( return (
<AppBar sx={{backgroundColor: "#4b2981"}} position="static"> <AppBar sx={{ backgroundColor: "#4b2981" }} position="static">
<Container> <Container>
<Toolbar disableGutters> <Toolbar disableGutters>
<StarIcon sx={{ display: { xs: "none", md: "flex" }, mr: 1 }} /> <StarIcon sx={{ display: { xs: "none", md: "flex" }, mr: 1 }} />
<Typography <Typography
variant="h6" variant="h6"
noWrap noWrap
@ -61,28 +68,47 @@ function Header() {
<Menu <Menu
id="menu-appbar" id="menu-appbar"
anchorEl={anchorElNav} anchorEl={anchorElNav}
anchorOrigin={{ anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
vertical: "bottom",
horizontal: "left",
}}
keepMounted keepMounted
transformOrigin={{ transformOrigin={{ vertical: "top", horizontal: "left" }}
vertical: "top",
horizontal: "left",
}}
open={Boolean(anchorElNav)} open={Boolean(anchorElNav)}
onClose={handleCloseNavMenu} onClose={handleCloseNavMenu}
sx={{ display: { xs: "block", md: "none" } }} sx={{ display: { xs: "block", md: "none" } }}
> >
<MenuItem onClick={handleCloseNavMenu} component="a" href="/categories"> {!user && (
<Typography textAlign="center">Categories</Typography> <MenuItem
</MenuItem> onClick={() => {
<MenuItem onClick={handleCloseNavMenu} component="a" href="/profile"> handleCloseNavMenu();
<Typography textAlign="center">Profile</Typography> navigate("/login");
</MenuItem> }}
<MenuItem onClick={handleCloseNavMenu} component="a" href="/create"> >
<Typography textAlign="center">Create</Typography> <Typography textAlign="center">Login</Typography>
</MenuItem> </MenuItem>
)}
{user && (
<span>
<MenuItem
onClick={() => {
handleCloseNavMenu();
navigate("/profile");
}}
>
<Typography textAlign="center">Profile</Typography>
</MenuItem>
{user.type === "admin" && (
<MenuItem
onClick={() => {
handleCloseNavMenu();
goToAdmin();
}}
>
<Typography textAlign="center">
Admin Dashboard
</Typography>
</MenuItem>
)}
</span>
)}
</Menu> </Menu>
</Box> </Box>
@ -91,7 +117,7 @@ function Header() {
variant="h5" variant="h5"
noWrap noWrap
component="a" component="a"
href="#home" href="/"
sx={{ sx={{
mr: 2, mr: 2,
display: { xs: "flex", md: "none" }, display: { xs: "flex", md: "none" },
@ -107,15 +133,33 @@ function Header() {
</Typography> </Typography>
<Box sx={{ flexGrow: 1, display: { xs: "none", md: "flex" } }}> <Box sx={{ flexGrow: 1, display: { xs: "none", md: "flex" } }}>
<Button href="/categories" sx={{ my: 2, color: "white", display: "block" }}> {!user && (
Categories <Button
</Button> onClick={() => navigate("/login")}
<Button href="/profile" sx={{ my: 2, color: "white", display: "block" }}> sx={{ my: 2, color: "white", display: "block" }}
Profile >
</Button> Login
<Button href="/create" sx={{ my: 2, color: "white", display: "block" }}> </Button>
Create )}
</Button>
{user && (
<>
<Button
onClick={() => navigate("/profile")}
sx={{ my: 2, color: "white", display: "block" }}
>
Profile
</Button>
{user.type === "admin" && (
<Button
onClick={goToAdmin}
sx={{ my: 2, color: "white", display: "block" }}
>
Admin Dashboard
</Button>
)}
</>
)}
</Box> </Box>
</Toolbar> </Toolbar>
</Container> </Container>

View File

@ -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 <Container>Loading...</Container>;
if (!user) return <Unauthorized />;
return <>{children}</>;
};

View File

@ -40,7 +40,7 @@ const Question = ({ question }: QuestionProps) => {
Difficulty: {question.difficulty} Difficulty: {question.difficulty}
</Typography> </Typography>
<AuthorMeta> <AuthorMeta>
<PersonIcon/> <PersonIcon />
<Typography variant="body1">{question.author}</Typography> <Typography variant="body1">{question.author}</Typography>
</AuthorMeta> </AuthorMeta>
</QuestionMetadata> </QuestionMetadata>

View File

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

View File

@ -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<void>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(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 (
<AuthContext.Provider
value={{ user: user ?? null, login, logout, isLoading }}
>
{children}
</AuthContext.Provider>
);
};

View File

@ -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<User>({
queryKey: ["me"],
queryFn: fetchMe,
enabled: !!localStorage.getItem("access_token"),
retry: false,
staleTime: 5 * 60 * 1000,
});

11
src/hooks/useLogin.ts Normal file
View File

@ -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<LoginResponse, any, LoginPayload>({
mutationFn: loginRequest,
});

View File

@ -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 (
<Container>
<Box sx={{ padding: 4, maxWidth: 360, margin: "auto" }}>
<Typography variant="h4" mb={3} textAlign="center">
Login
</Typography>
<form onSubmit={handleSubmit}>
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
fullWidth
margin="normal"
required
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
margin="normal"
required
/>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
sx={{ mt: "10px" }}
>
Login
</Button>
</form>
</Box>
</Container>
);
};
export default LoginPage;

View File

@ -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 (
<Container>
<Box sx={{ padding: 4, maxWidth: 360 }}>
<Typography variant="h4" mb={2}>
Profile
</Typography>
<Typography variant="body1" mb={2}>
Username: {user?.username}
</Typography>
<Typography variant="body1" mb={2}>
E-mail: {user?.email}
</Typography>
<Typography variant="body1" mb={2}>
Role: {user?.type}
</Typography>
<Button variant="contained" color="primary" onClick={logout}>
Logout
</Button>
{user?.type === "admin" && (
<Button
sx={{marginLeft: "10px"}}
variant="contained"
color="secondary"
onClick={() => navigate("/admin")}
>
Admin Dashboard
</Button>
)}
</Box>
</Container>
);
};
export default ProfilePage;

View File

@ -0,0 +1,18 @@
import { Box, Typography } from "@mui/material";
export default function Unauthorized() {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
flexDirection="column"
>
<Typography variant="h2" color="error" mb={2}>
401
</Typography>
<Typography variant="h5">Unauthorized</Typography>
<Typography>You do not have access to this page.</Typography>
</Box>
);
}

View File

@ -1,23 +1,33 @@
import { createBrowserRouter } from "react-router-dom"; import { createBrowserRouter } from "react-router-dom";
import MainLayout from "../layouts/MainLayout/MainLayout"; import MainLayout from "../layouts/MainLayout/MainLayout";
import IndexPage from "../pages/IndexPage/IndexPage"; 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 ([ const router = createBrowserRouter([
{ {
path:'/', path: "/",
element: <MainLayout/>, element: <MainLayout />,
children: [ children: [
{ {
index:true, index: true,
element: <IndexPage/> element: <IndexPage />,
}, },
{ {
path: "/questions", path: "/login",
element: <QuestionsPage/> element: <LoginPage />,
} },
] {
} path: "/profile",
]) element: (
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
),
},
],
},
]);
export default router; export default router;