diff --git a/.env b/.env deleted file mode 100644 index 0900ce4..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -VITE_API_URL=http://localhost:8000 -VITE_ENVIRONMENT=development -VITE_API_TOKEN=4!yHZwaR2yBt@LVA \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a878b9e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_API_URL = http://localhost:4000 +VITE_LOGIN_ROUTE = /login +VITE_ENVIRONMENT = development # accept "development", "staging", "production" diff --git a/.env.production b/.env.production deleted file mode 100644 index be655ca..0000000 --- a/.env.production +++ /dev/null @@ -1,3 +0,0 @@ -VITE_API_URL=https://api.production.com -VITE_ENVIRONMENT=production -VITE_API_TOKEN=${API_TOKEN_PRODUCTION} \ No newline at end of file diff --git a/.env.staging b/.env.staging deleted file mode 100644 index 34e8aa5..0000000 --- a/.env.staging +++ /dev/null @@ -1,3 +0,0 @@ -VITE_API_URL=https://api.staging.com -VITE_ENVIRONMENT=staging -VITE_API_TOKEN=${API_TOKEN_STAGING} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2107e24..e2ea484 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# Logs logs *.log npm-debug.log* @@ -23,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 489d512..aa73d72 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,37 +1,63 @@ import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; - import Home from "./pages/public/homePage"; import PublicLayout from "./layout/public/publicLayout"; import AdminLayout from "./layout/admin/adminLayout"; import Dashboard from "./pages/admin/dashboardPage"; +import Account from "./pages/admin/accountPage"; +import Login from "./pages/auth/loginPage"; +import RequestResetPassword from "./pages/auth/requestResetPasswordPage"; +import ResetPassword from "./pages/auth/resetPasswordPage"; +import LogoutPage from "./pages/auth/logoutPage"; +import RequireAuth from "./pages/admin/requireAuth"; +import { LOGIN_ROUTE } from "./constants/apiConstants"; +import { AuthProvider } from "./context/AuthContext"; export default function App() { return ( - - {/* FrontOffice routes */} - - - } /> - - - } - /> - {/* Admin routes */} - - - } /> - - - } - /> - + + + {/* Routes publiques */} + + + } /> + + + } + /> + + {/* Routes d'authentification */} + } /> + } /> + } + /> + } + /> + } /> + + {/* Routes admin */} + + + + } /> + } /> + + + + } + /> + + ); } diff --git a/src/components/ResponsiveLayout.tsx b/src/components/ResponsiveLayout.tsx index d9e37be..8cbe942 100644 --- a/src/components/ResponsiveLayout.tsx +++ b/src/components/ResponsiveLayout.tsx @@ -3,13 +3,15 @@ import Box, { type BoxProps } from "@mui/material/Box"; import Stack, { type StackProps } from "@mui/material/Stack"; import Paper, { type PaperProps } from "@mui/material/Paper"; import ImageList, { type ImageListProps } from "@mui/material/ImageList"; -import type { ResponsiveLayoutProps } from "../types/responsiveComponents"; +import type { ResponsiveLayoutProps } from "../types/responsiveTypes"; import { getResponsiveSx } from "../utils/responsiveUtils"; /** * Composant générique pour appliquer un layout responsive à n'importe quel composant MUI - * @param Component Le composant cible (Box, Stack, Paper, etc.) - * @param props Les props du composant cible + marginY/paddingY/rowGap + * @param {ComponentProps} Component Le composant cible (Box, Stack, Paper, etc.) + * @param {number | string} props.marginY Marge verticale (peut être un nombre multiplié par 8px ou une string CSS) + * @param {number | string} props.paddingY Padding vertical (peut être un nombre multiplié par 8px ou une string CSS) + * @param {number | string} props.rowGap Espace entre les lignes (peut être un nombre multiplié par 8px ou une string CSS) */ export function ResponsiveLayout( Component: React.ElementType, diff --git a/src/components/accountMenu.tsx b/src/components/accountMenu.tsx new file mode 100644 index 0000000..e872c42 --- /dev/null +++ b/src/components/accountMenu.tsx @@ -0,0 +1,58 @@ +import { mdiAccount } from "@mdi/js"; +import Icon from "@mdi/react"; +import { Button, IconButton, Menu, MenuItem } from "@mui/material"; +import CustomDialog from "./customDialog"; +import { Link, useLocation } from "react-router"; +import { useState } from "react"; + +export default function AccountMenu() { + const [logoutConfirm, setLogoutConfirm] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const handleCloseDialog = () => { + setLogoutConfirm(false); + setAnchorEl(null); + }; + const location = useLocation(); + + return ( + <> + setAnchorEl(e.currentTarget)} color="inherit"> + + + setAnchorEl(null)} + > + setAnchorEl(null)} + selected={location.pathname === "/admin/account"} + > + Mon compte + + setLogoutConfirm(true)}> + Me déconnecter + + + + + + + } + /> + + ); +} diff --git a/src/components/closableSnackbar.tsx b/src/components/closableSnackbar.tsx new file mode 100644 index 0000000..75f4ab2 --- /dev/null +++ b/src/components/closableSnackbar.tsx @@ -0,0 +1,44 @@ +import { type SnackbarCloseReason } from "@mui/material"; +import CustomSnackbar from "./customSnackBar"; +import type { ClosableSnackbarProps } from "../types/baseComponent"; + +/** + * Composant de snackbar personnalisée avec possibilité de fermeture manuelle ou automatique. + * Permet d'afficher des messages de succès, d'erreur, d'avertissement ou d'information à l'utilisateur, avec une gestion intégrée de l'ouverture et de la fermeture du snackbar. + * Utilise le composant CustomSnackbar pour afficher le message avec le style approprié en fonction de la gravité du message. + * @param {boolean} props.open Indique si le snackbar est ouvert ou fermé. + * @param {function} props.setOpen Fonction pour mettre à jour l'état d'ouverture du snackbar. + * @param {string} props.message Le message à afficher dans le snackbar. + * @param {function} props.onClose Fonction optionnelle à appeler lors de la fermeture du snackbar. + * @param {"success" | "error" | "warning" | "info"} props.severity La gravité du message, qui détermine le style du snackbar. + * @param {number} props.autohideDuration Durée en millisecondes avant que le snackbar ne se ferme automatiquement. Par défaut, 6000 ms (6 secondes). + */ +export default function ClosableSnackbar({ + open, + setOpen, + message, + onClose, + severity, + autohideDuration = 6000, +}: ClosableSnackbarProps) { + // Fonction de gestion de la fermeture du snackbar, qui peut être déclenchée manuellement ou automatiquement, et qui ignore les fermetures dues à un clic à l'extérieur du snackbar (clickaway). + const handleClose = ( + _: React.SyntheticEvent | Event, + reason?: SnackbarCloseReason, + ) => { + if (reason === "clickaway") { + return; + } + setOpen(false); + }; + + return ( + + ); +} diff --git a/src/components/customDialog.tsx b/src/components/customDialog.tsx new file mode 100644 index 0000000..8271bdf --- /dev/null +++ b/src/components/customDialog.tsx @@ -0,0 +1,35 @@ +import { + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; + +export default function CustomDialog({ + open, + onClose, + title, + content, + actions, +}: { + open: boolean; + onClose?: () => void; + content: string; + title?: string; + actions?: React.ReactNode; +}) { + return ( + + {title && {title}} + + {content} + + {actions && {actions}} + + ); +} diff --git a/src/components/customSnackBar.tsx b/src/components/customSnackBar.tsx new file mode 100644 index 0000000..45dd293 --- /dev/null +++ b/src/components/customSnackBar.tsx @@ -0,0 +1,28 @@ +import { Alert, Snackbar } from "@mui/material"; +import type { CustomSnackbarProps } from "../types/baseComponent"; + +/** + * Composant de snackbar personnalisée pour afficher des messages de succès, d'erreur, d'avertissement ou d'information à l'utilisateur. + * Utilise les composants Snackbar et Alert de Material-UI pour afficher le message avec le style approprié en fonction de la gravité du message. + * Permet de personnaliser le message, la gravité, la durée d'affichage et la fonction de fermeture du snackbar. + * @param {boolean} props.open Indique si le snackbar est ouvert ou fermé. + * @param {string} props.message Le message à afficher dans le snackbar. + * @param {"success" | "error" | "warning" | "info"} props.severity La gravité du message, qui détermine le style du snackbar. + * @param {function} props.onClose Fonction optionnelle à appeler lors de la fermeture du snackbar. + * @param {number} props.autohideDuration Durée en millisecondes avant que le snackbar ne se ferme automatiquement. Par défaut, 6000 ms (6 secondes). + */ +export default function CustomSnackbar({ + open, + message, + severity, + onClose, + autohideDuration, +}: CustomSnackbarProps) { + return ( + + + {message} + + + ); +} diff --git a/src/components/newPasswordFields.tsx b/src/components/newPasswordFields.tsx new file mode 100644 index 0000000..4466374 --- /dev/null +++ b/src/components/newPasswordFields.tsx @@ -0,0 +1,90 @@ +import type { NewPasswordFieldsProps } from "../types/baseComponent"; +import PasswordField from "./passwordField"; + +/** + * Composant pour les champs de saisie du nouveau mot de passe et de sa confirmation. + * Gère la validation du mot de passe et l'affichage des erreurs associées. + * @param props.newPassword Le mot de passe saisi par l'utilisateur. + * @param props.setNewPassword Fonction pour mettre à jour le mot de passe saisi. + * @param props.confirmPassword Le mot de passe de confirmation saisi par l'utilisateur. + * @param props.setConfirmPassword Fonction pour mettre à jour le mot de passe de confirmation. + * @param props.newPasswordError Message d'erreur lié au mot de passe saisi. + * @param props.setNewPasswordError Fonction pour mettre à jour le message d'erreur du mot de passe. + * @param props.confirmPasswordError Message d'erreur lié au mot de passe de confirmation. + * @param props.setConfirmPasswordError Fonction pour mettre à jour le message d'erreur du mot de passe de confirmation. + */ +export default function NewPasswordFields({ + newPassword, + setNewPassword, + confirmPassword, + setConfirmPassword, + newPasswordError, + setNewPasswordError, + confirmPasswordError, + setConfirmPasswordError, +}: NewPasswordFieldsProps) { + // Fonction de validation du mot de passe selon les critères définis. + const validatePassword = (pwd: string) => { + if (!pwd) return ""; + if (pwd.length < 20) { + return "Au moins 20 caractères."; + } + if (!/[A-Z]/.test(pwd)) { + return "Au moins une majuscule."; + } + if (!/[a-z]/.test(pwd)) { + return "Au moins une minuscule."; + } + if (!/[0-9]/.test(pwd)) { + return "Au moins un chiffre."; + } + if (!/[^A-Za-z0-9]/.test(pwd)) { + return "Au moins un caractère spécial."; + } + return ""; + }; + + // Gestion du changement de valeur du champ de nouveau mot de passe, avec validation en temps réel. + const handleNewPasswordChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setNewPassword(value); + const validationMsg = validatePassword(value); + setNewPasswordError(validationMsg); + if (value !== confirmPassword) { + setConfirmPasswordError("Les mots de passe doivent être identiques."); + } else { + setConfirmPasswordError(""); + } + }; + + // Gestion du changement de valeur du champ de confirmation du mot de passe, avec validation en temps réel. + const handleConfirmPasswordChange = ( + e: React.ChangeEvent, + ) => { + setConfirmPassword(e.target.value); + if (newPassword && e.target.value !== newPassword) { + setConfirmPasswordError("Les mots de passe doivent être identiques."); + } else { + setConfirmPasswordError(""); + } + }; + + return ( + <> + + + + ); +} diff --git a/src/components/passwordField.tsx b/src/components/passwordField.tsx new file mode 100644 index 0000000..5af5c15 --- /dev/null +++ b/src/components/passwordField.tsx @@ -0,0 +1,65 @@ +import { IconButton, InputAdornment, TextField } from "@mui/material"; +import Icon from "@mdi/react"; +import { mdiEyeOff, mdiEye } from "@mdi/js"; +import { useState } from "react"; +import type { PasswordFieldProps } from "../types/baseComponent"; + +/** + * Composant de champ de mot de passe avec option d'affichage du mot de passe. + * @param {string} props.label Le label du champ. + * @param {string} props.value La valeur actuelle du champ. + * @param {function} props.onChange Fonction de rappel pour gérer les changements de valeur. + * @param {boolean} props.error Indique si le champ est en erreur. + * @param {string} props.helperText Texte d'aide à afficher sous le champ. + * @param {string} props.errorText Texte d'erreur à afficher lorsque le champ est en erreur. + * @param {boolean} props.required Indique si le champ est requis. + */ +export default function PasswordField({ + label, + value, + onChange, + error, + helperText, + errorText, + required, + autoComplete, +}: PasswordFieldProps) { + const [showPassword, setShowPassword] = useState(false); + + return ( + + setShowPassword((v) => !v)} + edge="end" + aria-label={ + showPassword + ? "Masquer le mot de passe" + : "Afficher le mot de passe" + } + > + {showPassword ? ( + + ) : ( + + )} + + + ), + }, + }} + /> + ); +} diff --git a/src/components/resetPasswordLink.tsx b/src/components/resetPasswordLink.tsx new file mode 100644 index 0000000..5f505e4 --- /dev/null +++ b/src/components/resetPasswordLink.tsx @@ -0,0 +1,15 @@ +import { Link } from "@mui/material"; +import ResponsiveBodyTypography from "./responsiveBodyTypography"; + +/** + * Composant de lien pour la réinitialisation du mot de passe. + * Affiche un message invitant l'utilisateur à réinitialiser son mot de passe en cas d'oubli. + */ +export default function ResetPasswordLink() { + return ( + + Mot de passe oublié ?{" "} + Réinitialiser mon mot de passe + + ); +} diff --git a/src/components/responsiveBodyTypography.tsx b/src/components/responsiveBodyTypography.tsx index e371d7e..aed5cff 100644 --- a/src/components/responsiveBodyTypography.tsx +++ b/src/components/responsiveBodyTypography.tsx @@ -1,8 +1,15 @@ import Typography from "@mui/material/Typography"; import { verticalMediaQuery } from "../theme"; import { useTheme } from "@mui/material"; -import type { ResponsiveBodyTypographyProps } from "../types/responsiveComponents"; +import type { ResponsiveBodyTypographyProps } from "../types/responsiveTypes"; +/** + * Composant de typographie pour le corps de texte avec une taille de police responsive. + * La taille de la police s'adapte en fonction de la taille de l'écran, avec des limites pour éviter que le texte ne devienne trop petit ou trop grand. + * @param {string} props.variant Le variant de typographie à utiliser (ex: "body1", "body2", etc.). + * @param {React.ReactNode} props.children Le contenu à afficher à l'intérieur du composant. + * @param {object} props.props Propriétés supplémentaires à passer au composant Typography. + */ export default function ResponsiveBodyTypography({ variant, children, diff --git a/src/components/responsiveTitle.tsx b/src/components/responsiveTitle.tsx index 8fbf152..d4b849f 100644 --- a/src/components/responsiveTitle.tsx +++ b/src/components/responsiveTitle.tsx @@ -1,8 +1,15 @@ import Typography from "@mui/material/Typography"; import { verticalMediaQuery } from "../theme"; import { useTheme } from "@mui/material"; -import type { ResponsiveTitleProps } from "../types/responsiveComponents"; +import type { ResponsiveTitleProps } from "../types/responsiveTypes"; +/** + * Composant de typographie pour les titres avec une taille de police responsive. + * La taille de la police s'adapte en fonction de la taille de l'écran, avec des limites pour éviter que le texte ne devienne trop petit ou trop grand. + * @param {string} props.variant Le variant de typographie à utiliser (ex: "h1", "h2", etc.). + * @param {React.ReactNode} props.children Le contenu à afficher à l'intérieur du composant. + * @param {object} props.props Propriétés supplémentaires à passer au composant Typography. + */ export default function ResponsiveTitle({ variant, children, diff --git a/src/constants/apiConstants.ts b/src/constants/apiConstants.ts new file mode 100644 index 0000000..80b317b --- /dev/null +++ b/src/constants/apiConstants.ts @@ -0,0 +1,7 @@ +/** + * Fichier de constantes pour les URLs de l'API et les routes d'authentification. + * Les valeurs sont récupérées à partir des variables d'environnement, avec des valeurs par défaut pour le développement local. + */ + +export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:4000"; +export const LOGIN_ROUTE = import.meta.env.VITE_LOGIN_ROUTE || "/login"; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..2ea6785 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,205 @@ +import { createContext, useEffect, useState, type ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { loginApi } from "../services/authService"; +import type { AuthContextType } from "../types/authTypes"; + +export const AuthContext = createContext( + undefined, +); + +/** + * Fournit le contexte d'authentification pour l'application, gérant l'état de l'utilisateur, les fonctions de login/logout, et la vérification du token. + * @param {React.ReactNode} children Les composants enfants qui auront accès au contexte d'authentification. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + const [lastActivity, setLastActivity] = useState(Date.now()); + const [tokenExp, setTokenExp] = useState(null); + const navigate = useNavigate(); + + // Détecte l'activité de l'utilisateur pour le rafraîchissement du token + useEffect(() => { + const updateActivity = () => setLastActivity(Date.now()); + window.addEventListener("mousemove", updateActivity); + window.addEventListener("keydown", updateActivity); + window.addEventListener("mousedown", updateActivity); + window.addEventListener("touchstart", updateActivity); + return () => { + window.removeEventListener("mousemove", updateActivity); + window.removeEventListener("keydown", updateActivity); + window.removeEventListener("mousedown", updateActivity); + window.removeEventListener("touchstart", updateActivity); + }; + }, []); + + // Vérifie le token auprès de l'API + const checkAuth = async (): Promise => { + const token = localStorage.getItem("token"); + if (!token) { + setIsAuthenticated(false); + setUser(null); + setTokenExp(null); + return false; + } + try { + // Décoder l'expiration du token + const payload = (() => { + try { + const p = token.split(".")[1]; + return JSON.parse(atob(p.replace(/-/g, "+").replace(/_/g, "/"))); + } catch { + return null; + } + })(); + if (payload && payload.exp) { + setTokenExp(payload.exp); + } else { + setTokenExp(null); + } + const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:4000"; + const url = new URL("/verify-token", apiUrl).href; + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (res.ok) { + setIsAuthenticated(true); + try { + const data = await res.json(); + setUser(data.user || null); + } catch { + setUser(null); + } + return true; + } else { + setIsAuthenticated(false); + setUser(null); + setTokenExp(null); + localStorage.removeItem("token"); + return false; + } + } catch (err) { + setIsAuthenticated(false); + setUser(null); + setTokenExp(null); + localStorage.removeItem("token"); + return false; + } + }; + + // Login centralisé + const login = async ( + loginValue: string, + password: string, + ): Promise => { + setLoading(true); + try { + const token = await loginApi(loginValue, password); + localStorage.setItem("token", token); + // Décoder l'expiration du token dès le login + const payload = (() => { + try { + const p = token.split(".")[1]; + return JSON.parse(atob(p.replace(/-/g, "+").replace(/_/g, "/"))); + } catch { + return null; + } + })(); + if (payload && payload.exp) { + setTokenExp(payload.exp); + } else { + setTokenExp(null); + } + const ok = await checkAuth(); + setLoading(false); + return ok; + } catch (err) { + setLoading(false); + throw err; + } + }; + + // Logout centralisé + const logout = () => { + localStorage.removeItem("token"); + setIsAuthenticated(false); + setUser(null); + navigate("/"); + }; + + // Vérification initiale au chargement + useEffect(() => { + (async () => { + setLoading(true); + await checkAuth(); + setLoading(false); + })(); + }, []); + + const checkAndRefresh = async () => { + const token = localStorage.getItem("token"); + if (!token) return; + try { + const payload = (() => { + try { + const p = token.split(".")[1]; + return JSON.parse(atob(p.replace(/-/g, "+").replace(/_/g, "/"))); + } catch { + return null; + } + })(); + if (!payload || !payload.exp) return; + const now = Math.floor(Date.now() / 1000); + const timeLeft = payload.exp - now; + // Si actif dans les 10 dernières minutes et token expire dans moins de 5mn + if ( + Date.now() - lastActivity < 10 * 60 * 1000 && + timeLeft < 5 * 60 && + timeLeft > 0 + ) { + // Appel API pour rafraîchir le token (supposé route /refresh-token) + const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:4000"; + const url = new URL("/refresh-token", apiUrl).href; + const res = await fetch(url, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + if (data.token) { + localStorage.setItem("token", data.token); + await checkAuth(); + } + } + } + } catch {} + }; + + // Planifie le rafraîchissement du token 5 minutes avant son expiration, si l'utilisateur est actif + useEffect(() => { + if (!isAuthenticated || !tokenExp) return; + // Calcul du délai avant expiration - 5mn + const now = Math.floor(Date.now() / 1000); + const msBeforeRefresh = (tokenExp - 5 * 60 - now) * 1000; + if (msBeforeRefresh <= 0) return; // trop tard ou déjà expiré + const timeout = setTimeout(() => { + // Rafraîchir seulement si actif dans les 10 dernières minutes + if (Date.now() - lastActivity < 10 * 60 * 1000) { + checkAndRefresh(); + } + }, msBeforeRefresh); + return () => clearTimeout(timeout); + }, [isAuthenticated, tokenExp, lastActivity]); + + return ( + + {children} + + ); +} diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx new file mode 100644 index 0000000..e703ed9 --- /dev/null +++ b/src/hooks/useAuth.tsx @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import type { AuthContextType } from "../types/authTypes"; +import { AuthContext } from "../context/AuthContext"; + +/** + * Hook personnalisé pour accéder au contexte d'authentification. + * Doit être utilisé à l'intérieur d'un composant enveloppé par AuthProvider. + * @returns {AuthContextType} Le contexte d'authentification avec les fonctions et états disponibles. + */ +export function useAuth(): AuthContextType { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within an AuthProvider"); + return ctx; +} diff --git a/src/layout/admin/adminHeader.tsx b/src/layout/admin/adminHeader.tsx index 7dea570..0fedd95 100644 --- a/src/layout/admin/adminHeader.tsx +++ b/src/layout/admin/adminHeader.tsx @@ -1,18 +1,54 @@ import AppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import Button from "@mui/material/Button"; -import { Link as RouterLink } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; +import { useState } from "react"; +import CustomDialog from "../../components/customDialog"; +import AccountMenu from "../../components/accountMenu"; +import { IconButton, Menu, MenuItem } from "@mui/material"; +import Icon from "@mdi/react"; +import { mdiHome } from "@mdi/js"; +/** + * Entête de l'espace admin, avec des liens vers les différentes sections et la déconnexion. + */ export default function AdminHeader() { + const [anchorEl, setAnchorEl] = useState(null); + const location = useLocation(); + return ( - - + setAnchorEl(e.currentTarget)} + color="inherit" + > + + + setAnchorEl(null)} + > + setAnchorEl(null)} + selected={location.pathname === "/"} + > + Site + + setAnchorEl(null)} + selected={location.pathname === "/admin"} + > + Tableau de bord + + + ); diff --git a/src/layout/admin/adminLayout.tsx b/src/layout/admin/adminLayout.tsx index 3fc3fd9..a8fbadc 100644 --- a/src/layout/admin/adminLayout.tsx +++ b/src/layout/admin/adminLayout.tsx @@ -2,6 +2,10 @@ import AdminHeader from "./adminHeader"; import RootPaper from "../rootPaper"; import { ResponsivePaper } from "../../components/ResponsiveLayout"; +/** + * Layout principal de l'espace admin, avec une entête et une zone de contenu. + * Utilisé pour les pages de l'espace admin. + */ export default function AdminLayout({ children, }: { @@ -13,11 +17,13 @@ export default function AdminLayout({ diff --git a/src/layout/public/publicHeader.tsx b/src/layout/public/publicHeader.tsx index ecb9ba0..6ec8cd4 100644 --- a/src/layout/public/publicHeader.tsx +++ b/src/layout/public/publicHeader.tsx @@ -2,17 +2,35 @@ import AppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import Button from "@mui/material/Button"; import { Link as RouterLink } from "react-router-dom"; +import AccountMenu from "../../components/accountMenu"; +import { useAuth } from "../../hooks/useAuth"; +/** + * Entête pour les pages publiques, avec des liens vers l'accueil et l'espace admin. + * Utilisé sur les pages d'accueil, de connexion, etc. + */ export default function PublicHeader() { + const { isAuthenticated } = useAuth(); + return ( - - + {isAuthenticated && ( + <> + + + + )} ); diff --git a/src/layout/public/publicLayout.tsx b/src/layout/public/publicLayout.tsx index 25d5503..936f493 100644 --- a/src/layout/public/publicLayout.tsx +++ b/src/layout/public/publicLayout.tsx @@ -3,6 +3,10 @@ import PublicFooter from "./publicFooter"; import RootPaper from "../rootPaper"; import { ResponsivePaper } from "../../components/ResponsiveLayout"; +/** + * Layout principal pour les pages publiques (accueil, connexion, etc.). + * Affiche une entête, un pied de page et une zone de contenu centrale. + */ export default function PublicLayout({ children, }: { diff --git a/src/layout/rootPaper.tsx b/src/layout/rootPaper.tsx index 9a11eb3..cc13621 100644 --- a/src/layout/rootPaper.tsx +++ b/src/layout/rootPaper.tsx @@ -1,6 +1,18 @@ +import type { PaperProps } from "@mui/material"; import { ResponsivePaper } from "../components/ResponsiveLayout"; +import type { ResponsiveLayoutProps } from "../types/responsiveTypes"; -export default function RootPaper({ children }: { children: React.ReactNode }) { +/** + * Composant de layout principal pour les pages de l'application. + * Utilise un ResponsivePaper pour créer une zone de contenu centrale avec une hauteur minimale de 100vh. + * Permet d'aligner le contenu au centre de la page et d'ajouter des styles personnalisés via les props. + * @param {React.ReactNode} props.children Le contenu à afficher à l'intérieur du layout. + * @param {object} props.props Propriétés supplémentaires à passer au composant ResponsivePaper. + */ +export default function RootPaper({ + children, + ...props +}: ResponsiveLayoutProps<{ children: React.ReactNode }> & PaperProps) { return ( {children} diff --git a/src/pages/admin/accountPage.tsx b/src/pages/admin/accountPage.tsx new file mode 100644 index 0000000..42ce57e --- /dev/null +++ b/src/pages/admin/accountPage.tsx @@ -0,0 +1,240 @@ +import { Button, CircularProgress, Container, TextField } from "@mui/material"; +import ResponsiveTitle from "../../components/responsiveTitle"; +import { useState, useEffect } from "react"; +import { ResponsiveStack } from "../../components/ResponsiveLayout"; +import PasswordField from "../../components/passwordField"; +import ResetPasswordLink from "../../components/resetPasswordLink"; +import NewPasswordFields from "../../components/newPasswordFields"; +import ClosableSnackbar from "../../components/closableSnackbar"; +import CustomSnackbar from "../../components/customSnackBar"; + +/** + * Page de gestion du compte utilisateur dans l'espace admin. + * Permet de voir et modifier le login, l'email et le mot de passe du compte. + */ +export default function Account() { + const [loadingUser, setLoadingUser] = useState(true); + const [initialUser, setInitialUser] = useState<{ + login: string; + email: string; + } | null>(null); + const [login, setLogin] = useState(""); + const [email, setEmail] = useState(""); + const [userError, setUserError] = useState(""); + + const [currentPassword, setCurrentPassword] = useState(""); + const [passwordError, setPasswordError] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newPasswordError, setNewPasswordError] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [submitSuccess, setSubmitSuccess] = useState(null); + const [submitError, setSubmitError] = useState(""); + + const [successSnackbarOpen, setSuccessSnackbarOpen] = useState(false); + const [errorSnackbarOpen, setErrorSnackbarOpen] = useState(false); + + // Récupération des informations du compte à l'affichage de la page, avec gestion du chargement et des erreurs. + useEffect(() => { + const fetchUserInfo = async () => { + setLoadingUser(true); + setUserError(""); + try { + const token = localStorage.getItem("token"); + if (!token) throw new Error("Token manquant"); + const res = await fetch("http://localhost:4000/account", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) + throw new Error("Erreur lors de la récupération du compte"); + const data = await res.json(); + setLogin(data.login || ""); + setEmail(data.email || ""); + setInitialUser({ login: data.login || "", email: data.email || "" }); + } catch (e: any) { + setUserError(e.message || "Erreur inconnue"); + } finally { + setLoadingUser(false); + } + }; + fetchUserInfo(); + }, []); + + // Gestion des changements du mot de passe actuel pour valider les changements de compte. + const handleCurrentPasswordChange = ( + e: React.ChangeEvent, + ) => { + setCurrentPassword(e.target.value); + }; + + // Gestion de la soumission du formulaire de mise à jour du compte, avec validation et affichage des erreurs/succès. + const handleSubmit = async () => { + setSubmitError(""); + setSubmitSuccess(null); + setSubmitting(true); + try { + if (!initialUser) throw new Error("Utilisateur non chargé"); + const token = localStorage.getItem("token"); + if (!token) throw new Error("Token manquant"); + const payload: any = { oldPassword: currentPassword }; + if (login !== initialUser.login) payload.login = login; + if (email !== initialUser.email) payload.email = email; + if (newPassword) payload.newPassword = newPassword; + const res = await fetch("http://localhost:4000/account", { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + throw new Error(data.message || "Échec de la mise à jour"); + } + setSubmitSuccess(true); + // Mettre à jour l'état initial pour refléter les nouvelles valeurs + setInitialUser({ login, email }); + setNewPassword(""); + setConfirmPassword(""); + setCurrentPassword(""); + } catch (e: any) { + setSubmitError(e.message || "Erreur inconnue"); + setSubmitSuccess(false); + } finally { + setSubmitting(false); + } + }; + + // Affichage des snackbars de succès ou d'erreur en fonction des résultats des actions de mise à jour du compte ou de récupération des informations, avec gestion de l'ouverture et de la fermeture. + useEffect(() => { + if (submitSuccess) setSuccessSnackbarOpen(true); + if (userError || submitError) setErrorSnackbarOpen(true); + }, [submitSuccess, userError, submitError]); + + return ( + <> + + Mon compte + + + + {loadingUser ? ( + + ) : ( + + + + setLogin(e.target.value)} + autoComplete="username" + /> + setEmail(e.target.value)} + autoComplete="email" + /> + + + + + + + + + + + + + + + )} + + ); +} diff --git a/src/pages/admin/dashboardPage.tsx b/src/pages/admin/dashboardPage.tsx index 38cd7b6..73ca81f 100644 --- a/src/pages/admin/dashboardPage.tsx +++ b/src/pages/admin/dashboardPage.tsx @@ -1,5 +1,12 @@ import ResponsiveTitle from "../../components/responsiveTitle"; +/** + * Page d'accueil de l'espace admin. + */ export default function Dashboard() { - return Espace Admin; + return ( + + Espace Admin + + ); } diff --git a/src/pages/admin/requireAuth.tsx b/src/pages/admin/requireAuth.tsx new file mode 100644 index 0000000..2b778d4 --- /dev/null +++ b/src/pages/admin/requireAuth.tsx @@ -0,0 +1,28 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { useEffect } from "react"; +import { useAuth } from "../../hooks/useAuth"; + +/** + * Composant de protection des routes de l'espace admin. Redirige vers la page de login si l'utilisateur n'est pas authentifié. + * Vérifie le token à chaque navigation pour assurer la sécurité. + */ +export default function RequireAuth({ + children, +}: { + children: React.ReactNode; +}) { + const location = useLocation(); + const { isAuthenticated, loading, checkAuth } = useAuth(); + + // Vérifie l'authentification à chaque changement de route pour s'assurer que l'utilisateur est toujours authentifié. + useEffect(() => { + checkAuth(); + }, [location.pathname]); + + if (loading) return null; + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/src/pages/auth/loginPage.tsx b/src/pages/auth/loginPage.tsx new file mode 100644 index 0000000..55671e3 --- /dev/null +++ b/src/pages/auth/loginPage.tsx @@ -0,0 +1,143 @@ +import { TextField, Button, useTheme } from "@mui/material"; +import { useState } from "react"; +import { + ResponsivePaper, + ResponsiveStack, +} from "../../components/ResponsiveLayout"; +import RootPaper from "../../layout/rootPaper"; +import ResponsiveTitle from "../../components/responsiveTitle"; +import { Link as RouterLink, useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import PasswordField from "../../components/passwordField"; +import ResetPasswordLink from "../../components/resetPasswordLink"; +import { useAuth } from "../../hooks/useAuth"; +import CustomSnackbar from "../../components/customSnackBar"; + +/** + * Page de connexion à l'espace admin. + * Permet aux utilisateurs autorisés de se connecter pour accéder à l'administration du site. + */ +export default function Login() { + const theme = useTheme(); + + const { login: loginContext, loading, isAuthenticated } = useAuth(); + + const navigate = useNavigate(); + const [redirecting, setRedirecting] = useState(false); + + // Redirige automatiquement vers l'espace admin si l'utilisateur est déjà authentifié, avec gestion du chargement et de la redirection. + useEffect(() => { + if (isAuthenticated) { + setRedirecting(true); + navigate("/admin", { replace: true }); + } + }, [isAuthenticated, navigate]); + + const [login, setLogin] = useState(""); + const [password, setPassword] = useState(""); + + const [error, setError] = useState(""); + + // Gère la soumission du formulaire de connexion, en appelant la fonction de login du contexte d'authentification et en gérant les erreurs éventuelles. + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + try { + const ok = await loginContext(login, password); + if (ok) { + navigate("/admin"); + } else { + setError("Échec de l'authentification"); + } + } catch (err: any) { + setError(err.message || "Erreur de connexion"); + } + }; + + if (loading || redirecting) return null; + + return ( + + + {error && ( + + )} + + Accéder à l'espace Administrateur + + + setLogin(e.target.value)} + autoComplete="username" + disabled={loading} + required + /> + setPassword(e.target.value)} + error={!!error} + errorText={error} + required + /> + + + + + + + + + + + ); +} diff --git a/src/pages/auth/logoutPage.tsx b/src/pages/auth/logoutPage.tsx new file mode 100644 index 0000000..51a5776 --- /dev/null +++ b/src/pages/auth/logoutPage.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../../hooks/useAuth"; + +/** + * Page de déconnexion. Supprime les tokens d'authentification et redirige vers la page d'accueil. + * Utilisée pour permettre aux utilisateurs de se déconnecter proprement de l'application. + */ +export default function LogoutPage() { + const { logout } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + logout(); + navigate("/", { replace: true }); + }, [logout]); + + return null; +} diff --git a/src/pages/auth/requestResetPasswordPage.tsx b/src/pages/auth/requestResetPasswordPage.tsx new file mode 100644 index 0000000..bdbb8d2 --- /dev/null +++ b/src/pages/auth/requestResetPasswordPage.tsx @@ -0,0 +1,142 @@ +import { TextField, Button, useTheme } from "@mui/material"; +import { + ResponsivePaper, + ResponsiveStack, +} from "../../components/ResponsiveLayout"; +import RootPaper from "../../layout/rootPaper"; +import ResponsiveTitle from "../../components/responsiveTitle"; +import { Link as RouterLink } from "react-router-dom"; +import { API_URL, LOGIN_ROUTE } from "../../constants/apiConstants"; +import { useState } from "react"; +import { useAuth } from "../../hooks/useAuth"; +import CustomSnackbar from "../../components/customSnackBar"; +import ClosableSnackbar from "../../components/closableSnackbar"; + +/** + * Page de demande de réinitialisation du mot de passe. + * Permet aux utilisateurs de demander un lien de réinitialisation en fournissant leur adresse e-mail. + */ +export default function RequestResetPassword() { + const theme = useTheme(); + + const { isAuthenticated } = useAuth(); + + const [email, setEmail] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + + const [successSnackbarOpen, setSuccessSnackbarOpen] = useState(false); + + // Gère la soumission du formulaire de réinitialisation, en envoyant une requête au backend avec le token et le nouveau mot de passe, et en gérant les réponses pour afficher les messages appropriés. + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitError(""); + setSubmitting(true); + try { + const res = await fetch(`${API_URL}/auth/reset`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + throw new Error( + data.message || "Échec de la demande de réinitialisation", + ); + } + setSuccessSnackbarOpen(true); + } catch (e: any) { + setSubmitError(e.message || "Erreur inconnue"); + } finally { + setSubmitting(false); + } + }; + + return ( + + {submitError && ( + + )} + + + + Demander la réinitialisation + de mon mot de passe + + + setEmail(e.target.value)} + required + disabled={submitting} + autoComplete="email" + /> + + + + + + + + + + ); +} diff --git a/src/pages/auth/resetPasswordPage.tsx b/src/pages/auth/resetPasswordPage.tsx new file mode 100644 index 0000000..e0d5f30 --- /dev/null +++ b/src/pages/auth/resetPasswordPage.tsx @@ -0,0 +1,151 @@ +import { Alert, Button, Snackbar, useTheme } from "@mui/material"; +import { + ResponsivePaper, + ResponsiveStack, +} from "../../components/ResponsiveLayout"; +import ResponsiveTitle from "../../components/responsiveTitle"; +import RootPaper from "../../layout/rootPaper"; +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import NewPasswordFields from "../../components/newPasswordFields"; +import { Link as RouterLink } from "react-router-dom"; +import { API_URL, LOGIN_ROUTE } from "../../constants/apiConstants"; +import { useAuth } from "../../hooks/useAuth"; +import ClosableSnackbar from "../../components/closableSnackbar"; +import CustomSnackbar from "../../components/customSnackBar"; + +/** + * Page de réinitialisation du mot de passe. Permet aux utilisateurs de réinitialiser leur mot de passe en fournissant un nouveau mot de passe et une confirmation, après avoir cliqué sur le lien de réinitialisation reçu par e-mail. + * Gère la validation des champs, l'envoi de la requête de réinitialisation au backend et l'affichage des messages de succès ou d'erreur. + */ +export default function ResetPassword() { + const theme = useTheme(); + + const { isAuthenticated } = useAuth(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token") || ""; + + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [newPasswordError, setNewPasswordError] = useState(""); + const [confirmPasswordError, setConfirmPasswordError] = useState(""); + + const [submitError, setSubmitError] = useState(""); + const [submitSuccess, setSubmitSuccess] = useState(false); + const [submitting, setSubmitting] = useState(false); + + // Gère la soumission du formulaire de réinitialisation, en envoyant une requête au backend avec le token et le nouveau mot de passe, et en gérant les réponses pour afficher les messages appropriés. + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitError(""); + setSubmitSuccess(false); + setSubmitting(true); + try { + const res = await fetch(`${API_URL}/auth/reset/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, newPassword }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + throw new Error( + data.message || "Échec de la réinitialisation du mot de passe", + ); + } + setSubmitSuccess(true); + } catch (e: any) { + setSubmitError(e.message || "Erreur inconnue"); + setSubmitSuccess(false); + } finally { + setSubmitting(false); + } + }; + + return ( + + {submitError && ( + + )} + + + + Réinitialiser mon mot de passe + + + + + + + + + + + + + ); +} diff --git a/src/pages/public/homePage.tsx b/src/pages/public/homePage.tsx index 3a828e4..406632a 100644 --- a/src/pages/public/homePage.tsx +++ b/src/pages/public/homePage.tsx @@ -3,9 +3,12 @@ import ResponsiveTitle from "../../components/responsiveTitle"; import ResponsiveBodyTypography from "../../components/responsiveBodyTypography"; import { ResponsivePaper } from "../../components/ResponsiveLayout"; +/** + * Page d'accueil publique du site. + */ export default function Home() { const theme = useTheme(); - console.log(theme); + return ( <> Hello World ! diff --git a/src/services/appolloClient.ts b/src/services/appolloClient.ts index 7732dc2..de6f563 100644 --- a/src/services/appolloClient.ts +++ b/src/services/appolloClient.ts @@ -1,5 +1,9 @@ import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +/** + * Configure et exporte une instance d'Apollo Client pour interagir avec le backend GraphQL. + * Utilise un cache en mémoire pour stocker les données récupérées et un lien HTTP pour communiquer avec le serveur GraphQL à l'URL spécifiée. + */ const apolloClient = new ApolloClient({ cache: new InMemoryCache(), link: new HttpLink({ diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..32e9079 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,37 @@ +import { API_URL, LOGIN_ROUTE } from "../constants/apiConstants"; +import { joinUrl } from "../utils/urlUtils"; + +/** + * Service d'authentification pour gérer les interactions avec l'API d'authentification. + * Fournit une fonction de login qui envoie les identifiants de l'utilisateur au backend et récupère un token d'authentification en cas de succès. + * Gère également les erreurs de connexion, notamment les problèmes de réseau ou les réponses invalides du serveur. + */ +export async function loginApi( + login: string, + password: string, +): Promise { + try { + const response = await fetch(joinUrl(API_URL, LOGIN_ROUTE), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ login, password }), + }); + if (!response.ok) { + throw new Error("Échec de l'authentification"); + } + const data = await response.json(); + if (!data.token) { + throw new Error("Token manquant dans la réponse"); + } + return data.token; + } catch (err: any) { + if (err instanceof TypeError && err.message === "Failed to fetch") { + throw new Error( + "Impossible de contacter l'API. Vérifiez que le serveur est bien démarré et que CORS est autorisé.", + ); + } + throw err; + } +} diff --git a/src/theme.ts b/src/theme.ts index ffc5f22..bc41092 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,15 +1,24 @@ import { createTheme, type Theme } from "@mui/material/styles"; +// Breakpoints verticaux basés sur la hauteur de l'écran, en complément des breakpoints horizontaux classiques. const verticalBreakpoints = { tight: 0, compact: 552, loose: 792, }; +/** + * Fonction utilitaire pour générer des media queries basées sur la hauteur de l'écran. + * Permet de créer des breakpoints verticaux pour adapter le design en fonction de la hauteur de l'écran, en complément des breakpoints horizontaux classiques. + * Utilise les valeurs définies dans verticalBreakpoints pour générer des media queries "up" (min-height) ou "down" (max-height). + * @param {keyof typeof verticalBreakpoints} key Le nom du breakpoint vertical à utiliser (tight, compact, loose). + * @param {"up" | "down"} direction La direction de la media query, soit "up" pour min-height, soit "down" pour max-height. Par défaut, "up". + * @returns {string} La media query CSS correspondante à appliquer dans les styles. + */ export const verticalMediaQuery = ( key: keyof typeof verticalBreakpoints, direction: "up" | "down" = "up", -) => { +): string => { const px = verticalBreakpoints[key]; if (direction === "up") { return `@media (min-height:${px}px)`; @@ -18,6 +27,7 @@ export const verticalMediaQuery = ( } }; +// Thème personnalisé pour l'application, basé sur le thème sombre de Material-UI avec des couleurs et des typographies adaptées. const baseTheme = createTheme({ palette: { mode: "dark", @@ -75,9 +85,184 @@ const baseTheme = createTheme({ }, }, }, + MuiTextField: { + defaultProps: { + variant: "outlined", + fullWidth: true, + }, + styleOverrides: { + root: { + "& .MuiInputLabel-root": { + fontSize: "1rem", + lineHeight: 1, + letterSpacing: "normal", + "&.MuiInputLabel-shrink": { + transform: "translate(14px, -6px) scale(0.75) ", + }, + "&.Mui-focused": { + transform: "translate(14px, -6px) scale(0.75) ", + }, + }, + "& .MuiInputBase-root": { + borderRadius: "4px", + fontSize: "1rem", + lineHeight: 1, + letterSpacing: "normal", + "& .MuiInputBase-input": { + padding: "12px 16px", + minHeight: "1.5rem", + "&.MuiInputBase-inputAdornedEnd": { + paddingRight: "8px", + }, + }, + "&.MuiInputBase-adornedEnd": { + paddingRight: "0px", + + "& .MuiInputAdornment-root": { + margin: "0px", + }, + }, + }, + "& .MuiFormHelperText-root": { + fontSize: "0.75rem", + lineHeight: 2, + marginTop: "0px", + }, + }, + }, + }, + MuiButton: { + defaultProps: { + variant: "contained", + }, + styleOverrides: { + root: { + borderRadius: "4px", + fontSize: "1rem", + lineHeight: 1.5, + letterSpacing: "normal", + marginTop: "4px", + marginBottom: "4px", + padding: "8px 16px", + textAlign: "center", + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + "&.MuiIconButton-sizeMedium": { + margin: "4px", + }, + "&.MuiIconButton-sizeSmall": { + padding: "6px", + fontSize: "1.25rem", + }, + "& .MuiSvgIcon-root": { + fontSize: "inherit", + }, + }, + }, + }, + MuiCircularProgress: { + defaultProps: { + size: "48px", + thickness: 2, + }, + }, + MuiSnackbar: { + defaultProps: { + anchorOrigin: { vertical: "bottom", horizontal: "right" }, + }, + styleOverrides: { + root: { + right: "16px !important", + }, + }, + }, + MuiAlert: { + defaultProps: { + variant: "outlined", + severity: "info", + }, + styleOverrides: { + root: { + borderRadius: "4px", + fontSize: "1rem", + lineHeight: 1.5, + letterSpacing: "normal", + padding: "7.2px 15.2px", + gap: "8px", + "& .MuiAlert-icon": { + fontSize: "1.25rem", + margin: "0px", + padding: "6px 0px", + }, + "& .MuiAlert-message": { + padding: "4px 0px", + }, + "& .MuiAlert-action": { + margin: "0px -6px", + padding: "0px", + }, + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + borderRadius: "8px", + padding: "24px 16px", + margin: "48px 32px", + gap: "12px", + }, + }, + }, + MuiDialogTitle: { + styleOverrides: { + root: { + fontSize: "1.5rem", + lineHeight: 1, + letterSpacing: "normal", + padding: "0px", + }, + }, + }, + MuiDialogContent: { + styleOverrides: { + root: { + fontSize: "1rem", + lineHeight: 1.5, + letterSpacing: "normal", + padding: "0px", + }, + }, + }, + MuiDialogContentText: { + styleOverrides: { + root: { + fontSize: "1rem", + lineHeight: 1.5, + letterSpacing: "normal", + }, + }, + }, + MuiDialogActions: { + styleOverrides: { + root: { + padding: "0px", + gap: "8px", + "& .MuiButton-root": { + marginLeft: "0px", + marginRight: "0px", + }, + }, + }, + }, }, }); +// Ajout de formes personnalisées au thème pour une plus grande flexibilité dans les styles des composants. const customShapes = { borderRadiusXs: "2px", borderRadiusSm: "4px", @@ -87,7 +272,6 @@ const customShapes = { borderRadiusXxl: "32px", borderRadiusFull: "9999px", }; - Object.assign(baseTheme.shape, customShapes); const theme = baseTheme as Theme; diff --git a/src/types/authTypes.ts b/src/types/authTypes.ts new file mode 100644 index 0000000..246a6aa --- /dev/null +++ b/src/types/authTypes.ts @@ -0,0 +1,8 @@ +export interface AuthContextType { + isAuthenticated: boolean; + loading: boolean; + user: any; + login: (login: string, password: string) => Promise; + logout: () => void; + checkAuth: () => Promise; +} diff --git a/src/types/baseComponent.ts b/src/types/baseComponent.ts new file mode 100644 index 0000000..bb6ce72 --- /dev/null +++ b/src/types/baseComponent.ts @@ -0,0 +1,38 @@ +import type { SnackbarCloseReason } from "@mui/material"; + +export interface PasswordFieldProps { + label: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + error?: boolean; + helperText?: string; + errorText?: string; + required?: boolean; + autoComplete?: string; +} + +export interface NewPasswordFieldsProps { + newPassword: string; + setNewPassword: (pwd: string) => void; + confirmPassword: string; + setConfirmPassword: (pwd: string) => void; + newPasswordError: string; + setNewPasswordError: (msg: string) => void; + confirmPasswordError: string; + setConfirmPasswordError: (msg: string) => void; +} + +export interface CustomSnackbarProps { + open: boolean; + message: string; + severity?: "success" | "error" | "warning" | "info"; + onClose?: ( + event: React.SyntheticEvent | Event, + reason?: SnackbarCloseReason, + ) => void; + autohideDuration?: number; +} + +export interface ClosableSnackbarProps extends CustomSnackbarProps { + setOpen: (open: boolean) => void; +} diff --git a/src/types/responsiveComponents.ts b/src/types/responsiveTypes.ts similarity index 60% rename from src/types/responsiveComponents.ts rename to src/types/responsiveTypes.ts index 7249072..73dca98 100644 --- a/src/types/responsiveComponents.ts +++ b/src/types/responsiveTypes.ts @@ -16,3 +16,17 @@ export type ResponsiveLayoutProps

= P & { rowGap?: string | number; children: React.ReactNode; }; + +export type GetResponsiveSxProps = { + marginY?: string; + paddingY?: string; + rowGap?: string; +}; + +export type ResponsiveSxProps = { + maxWidth: number; + marginY: { xs: string; sm: string; md: string }; + paddingY: { xs: string; sm: string; md: string }; + rowGap: { xs: string; sm: string; md: string }; + [key: string]: any; +}; diff --git a/src/utils/authUtils.ts b/src/utils/authUtils.ts new file mode 100644 index 0000000..f07c294 --- /dev/null +++ b/src/utils/authUtils.ts @@ -0,0 +1,29 @@ +/** + * Utilitaires liés à l'authentification, notamment la gestion des tokens JWT et la vérification de l'état d'authentification de l'utilisateur. + * Fournit des fonctions pour décoder les tokens JWT et vérifier leur validité en fonction de leur date d'expiration. + * @param {string} token Le token JWT à décoder. + * @returns {any | null} Le payload décodé du token, ou null en cas d'erreur de décodage. + */ +function decodeJwt(token: string): any | null { + try { + const payload = token.split(".")[1]; + return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))); + } catch { + return null; + } +} + +/** + * Vérifie si l'utilisateur est actuellement authentifié en vérifiant la présence d'un token JWT valide dans le stockage local. + * Décodé le token pour vérifier sa date d'expiration et s'assurer qu'il est encore valide. + * @returns {boolean} true si l'utilisateur est authentifié, false sinon. + */ +export function isAuthenticated(): boolean { + const token = localStorage.getItem("token"); + if (!token) return false; + const payload = decodeJwt(token); + if (!payload || !payload.exp) return false; + // exp est en secondes depuis epoch + const now = Math.floor(Date.now() / 1000); + return payload.exp > now; +} diff --git a/src/utils/responsiveUtils.ts b/src/utils/responsiveUtils.ts index b6057e4..faeb391 100644 --- a/src/utils/responsiveUtils.ts +++ b/src/utils/responsiveUtils.ts @@ -1,11 +1,24 @@ import { useTheme } from "@mui/material"; import { verticalMediaQuery } from "../theme"; +import type { + GetResponsiveSxProps, + ResponsiveSxProps, +} from "../types/responsiveTypes"; +/** + * Utilitaire pour générer des styles responsives basés sur les propriétés de marge, de padding et d'espacement entre les éléments. + * Utilise les breakpoints et les fonctions de thème de Material-UI pour créer des styles qui s'adaptent à différentes tailles d'écran et orientations. + * Permet de limiter les valeurs de marge, de padding et d'espacement à des maximums définis dans le thème pour éviter des espacements excessifs sur les grands écrans. + * @param {string} props.marginY La marge verticale à appliquer, qui sera limitée par les breakpoints du thème. + * @param {string} props.paddingY Le padding vertical à appliquer, qui sera limité par les breakpoints du thème. + * @param {string} props.rowGap L'espacement entre les éléments (row gap) à appliquer, qui sera limité par les breakpoints du thème. + * @returns {{ maxWidth: number; marginY: { xs: string; sm: string; md: string }; paddingY: { xs: string; sm: string; md: string }; rowGap: { xs: string; sm: string; md: string }; [key: string]: any;}} Un objet de styles responsives à appliquer aux composants. + */ export function getResponsiveSx({ marginY = "0px", paddingY = "0px", rowGap = "0px", -}) { +}: GetResponsiveSxProps): ResponsiveSxProps { const theme = useTheme(); const maxSpacing = theme.spacing(24); diff --git a/src/utils/urlUtils.ts b/src/utils/urlUtils.ts new file mode 100644 index 0000000..8d5dc81 --- /dev/null +++ b/src/utils/urlUtils.ts @@ -0,0 +1,14 @@ +/** + * Concatène proprement une base d'URL et un chemin, sans double slash. + * @param {string} baseUrl ex: http://localhost:4000/ + * @param {string} path ex: /4ntjnra6 + * @returns {string} ex: http://localhost:4000/4ntjnra6 + */ +export function joinUrl(baseUrl: string, path: string): string { + try { + return new URL(path, baseUrl).href; + } catch (err) { + console.error("URL invalide:", err); + return baseUrl + path; + } +}