diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index d7008dd..d85e247 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -87,6 +87,17 @@ export const getCurrentUser = async (req: Request, res: Response) => { } }; +export const getUserContactInformation = async (req: Request, res: Response) => { + const { userId } = req.params; + + try { + const userContactInfo = await user_service.getUserContactInformation(parseInt(userId)); + Ok(res, { data: userContactInfo }); + } catch { + Error(res, { msg: 'Erreur lors de la récupération des informations de contact de l\'utilisateur.' }); + } +}; + export const updateProfile = async (req: Request, res: Response) => { const userId = req.user?.userId; const { branch, contact } = req.body; diff --git a/backend/src/routes/user.routes.ts b/backend/src/routes/user.routes.ts index ae2f9d2..c83d3fa 100644 --- a/backend/src/routes/user.routes.ts +++ b/backend/src/routes/user.routes.ts @@ -8,6 +8,7 @@ const userRouter = express.Router(); userRouter.get('/admin/getusersbypermission', checkRole("Admin", []), userController.getUsersByPermission); userRouter.patch('/admin/user/:userId', checkRole("Admin", []), userController.adminUpdateUser); userRouter.delete('/admin/user/:userId', checkRole("Admin", []), userController.adminDeleteUser); +userRouter.get('/admin/getusercontactinformation/:userId', checkRole("Admin", []), userController.getUserContactInformation); userRouter.get('/admin/getusers', checkRole("Admin", ["Respo CE"]), userController.getUsersAdmin); userRouter.post('/admin/syncnewstudent', checkRole("Admin", []), userController.syncNewstudent); diff --git a/backend/src/schemas/Relational/userinformation.schema.ts b/backend/src/schemas/Relational/userinformation.schema.ts new file mode 100644 index 0000000..bfdceea --- /dev/null +++ b/backend/src/schemas/Relational/userinformation.schema.ts @@ -0,0 +1,11 @@ +import { pgTable, integer, text } from "drizzle-orm/pg-core"; +import { userSchema } from "../Basic/user.schema"; + +export const userInformationSchema = pgTable("user_informations", { + user_id: integer("user_id").primaryKey().references(() => userSchema.id, { onDelete: "cascade" }), + urgency_contact_name: text("urgency_contact_name"), + urgency_contact_phone: integer("urgency_contact_phone"), + contact_CE: text("contact_CE"), +}); + +export type UserInformation = typeof userInformationSchema.$inferSelect; \ No newline at end of file diff --git a/backend/src/services/user.service.ts b/backend/src/services/user.service.ts index c457be9..5ba7bcb 100644 --- a/backend/src/services/user.service.ts +++ b/backend/src/services/user.service.ts @@ -6,6 +6,7 @@ import { registrationSchema } from '../schemas/Relational/registration.schema'; import { getFaction } from './faction.service'; import { getUserRoles } from './role.service'; import { getTeam, getTeamFaction, getUserTeam } from './team.service'; +import { userInformationSchema } from '../schemas/Relational/userinformation.schema'; // Fonction pour récupérer un utilisateur par email export const getUserByEmail = async (email: string) => { @@ -133,6 +134,23 @@ export const getUsers = async () => { } }; +export const getUserContactInformation = async (userId: number) => { + try { + const user = await db.select( + { + userId: userInformationSchema.user_id, + urgency_contact_name: userInformationSchema.urgency_contact_name, + urgency_contact_phone: userInformationSchema.urgency_contact_phone, + contact_CE: userInformationSchema.contact_CE + } + ).from(userInformationSchema).where(eq(userInformationSchema.user_id, userId)); + return user[0]; + } catch (err) { + console.error('Erreur lors de la récupération des informations de contact de l\'utilisateur ', err); + throw new Error('Erreur de base de données'); + } +}; + export const getUsersAll = async () => { try { const users = await db.select().from(userSchema); diff --git a/frontend/src/components/Admin/adminUser.tsx b/frontend/src/components/Admin/adminUser.tsx index e1a7a74..79a1fa8 100644 --- a/frontend/src/components/Admin/adminUser.tsx +++ b/frontend/src/components/Admin/adminUser.tsx @@ -2,10 +2,11 @@ import { useEffect, useState } from 'react'; import Select from 'react-select'; import Swal from 'sweetalert2'; -import { type User } from '../../interfaces/user.interface'; +import { type User,type UserContactInformation } from '../../interfaces/user.interface'; import { renewTokenUser, requestPasswordUser } from '../../services/requests/auth.service'; import { deleteUserByAdmin, + getUserContactInformation, getUsersAdmin, syncnewStudent, updateUserByAdmin, @@ -46,6 +47,7 @@ export const AdminUser = () => { const [users, setUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [formData, setFormData] = useState>({}); + const [contactInformation, setContactInformation] = useState>({}); useEffect(() => { const fetchUsers = async () => { @@ -55,11 +57,12 @@ export const AdminUser = () => { fetchUsers(); }, []); - const handleUserSelect = (option: any) => { + const handleUserSelect = async (option: any) => { const user = users.find((u) => u.userId === option.value); if (user) { setSelectedUser(user); setFormData({ ...user }); + setContactInformation({ ...(await getUserContactInformation(user.userId)) }); } }; @@ -165,104 +168,132 @@ export const AdminUser = () => { }; return ( - - - - 👤 Gérer un utilisateur - - - - - - - - -

- Attention : la donnée récupérée dépend de la date de synchro choisie -

- - b.value === formData.branch) || null} - onChange={handleSelectChange('branch')} - options={branchOptions} - placeholder="Choisir une filière" - isClearable - /> - - - - ({ + value: u.userId, + label: `${u.firstName} ${u.lastName} (${u.email})`, + }))} + onChange={handleUserSelect} + /> + + {selectedUser && ( +
+ + + + +

+ Attention : la donnée récupérée dépend de la date de synchro choisie +

+ + b.value === formData.branch) || null} + onChange={handleSelectChange('branch')} + options={branchOptions} + placeholder="Choisir une filière" + isClearable + /> + + + + + -
- )} -
-
+ + + + )} + ); }; diff --git a/frontend/src/components/auth/authForm.tsx b/frontend/src/components/auth/authForm.tsx index bf98f29..64f0f32 100644 --- a/frontend/src/components/auth/authForm.tsx +++ b/frontend/src/components/auth/authForm.tsx @@ -31,7 +31,7 @@ export const AuthForm = () => { const token = await loginUser(formData.email, formData.password); if (token) { localStorage.setItem("authToken", token); - window.location.href = "/home"; + window.location.href = "/home?login=true"; } } catch (err: any) { console.error(err); diff --git a/frontend/src/components/home/urgencyModal.tsx b/frontend/src/components/home/urgencyModal.tsx new file mode 100644 index 0000000..343fe2a --- /dev/null +++ b/frontend/src/components/home/urgencyModal.tsx @@ -0,0 +1,20 @@ +import { useSearchParams } from 'react-router-dom'; + +import Modal from '../ui/modal'; + +function UrgencyModal() { + const [searchParams, setSearchParams] = useSearchParams(); + + const isLogin = searchParams.get('login') === 'true'; + + return ( + setSearchParams({})} + buttons={null} + /> + ); +} + +export default UrgencyModal; diff --git a/frontend/src/components/navbar.tsx b/frontend/src/components/navbar.tsx index f0975ba..12a6e1c 100644 --- a/frontend/src/components/navbar.tsx +++ b/frontend/src/components/navbar.tsx @@ -1,10 +1,12 @@ -import { Bars4Icon, CogIcon, HomeIcon, UsersIcon } from "@heroicons/react/24/outline"; -import { XMarkIcon, } from "@heroicons/react/24/solid"; -import { AnimatePresence, motion } from "framer-motion"; -import { Fragment, useEffect, useState } from "react"; -import { NavLink, useLocation } from "react-router-dom"; +import { Bars4Icon, CogIcon, HomeIcon, UsersIcon } from '@heroicons/react/24/outline'; +import { XMarkIcon } from '@heroicons/react/24/solid'; +import { AnimatePresence, motion } from 'framer-motion'; +import { Fragment, useEffect, useState } from 'react'; +import { NavLink, useLocation, useSearchParams } from 'react-router-dom'; -import { decodeToken, getToken } from "../services/requests/auth.service"; +import { decodeToken, getToken } from '../services/requests/auth.service'; +import { getCurrentUserContactInformation } from '../services/requests/user.service'; +import { Button } from './ui/button'; interface NavItem { label: string; @@ -12,10 +14,10 @@ interface NavItem { icon?: React.ComponentType>; rolesAllowed?: string[]; // ["Admin", "Respo CE", ...] public?: boolean; - showWhen?: "always" | "auth" | "guest"; - kind?: "link" | "action"; + showWhen?: 'always' | 'auth' | 'guest'; + kind?: 'link' | 'action'; onClick?: () => void; - children?: NavItem[]; // pour dropdown + children?: NavItem[]; // pour dropdown } export const Navbar = () => { @@ -23,223 +25,222 @@ export const Navbar = () => { const token = getToken(); const [menuOpen, setMenuOpen] = useState(false); const isAuthenticated = Boolean(token); - const { userPermission, userRoles = [] } = token ? decodeToken(token) : { userPermission: undefined, userRoles: [] }; - const roles = [userPermission, ...userRoles.map(r => r.roleName)].filter(Boolean) as string[]; + const { userPermission, userRoles = [] } = token + ? decodeToken(token) + : { userPermission: undefined, userRoles: [] }; + const roles = [userPermission, ...userRoles.map((r) => r.roleName)].filter(Boolean) as string[]; + const [hasContactInformation, setHasContactInformation] = useState(false); + const [, setSearchParams] = useSearchParams(); const handleLogout = () => { - localStorage.removeItem("authToken"); - window.location.href = "/"; + localStorage.removeItem('authToken'); + window.location.href = '/'; }; useEffect(() => { setMenuOpen(false); + const fetchContactInformation = async () => { + try { + const contactInfo = await getCurrentUserContactInformation(); + setHasContactInformation(contactInfo.urgency_contact_phone !== null); + } catch (error) { + console.error('Erreur lors de la récupération des informations de contact :', error); + } + }; + + if (isAuthenticated) { + fetchContactInformation(); + } }, [pathname]); const navItems: NavItem[] = [ - { label: "Home", to: "/home", icon: HomeIcon }, - { label: "Plannings", to: "/plannings" }, - { label: "Parrainage", to: "/parrainage" }, - { label: "Challenges", to: "/challenges" }, - { label: "Mes Actus", to: "/news" }, + { label: 'Home', to: '/home', icon: HomeIcon }, + { label: 'Plannings', to: '/plannings' }, + { label: 'Parrainage', to: '/parrainage' }, + { label: 'Challenges', to: '/challenges' }, + { label: 'Mes Actus', to: '/news' }, { - label: "Permanences", - to: "#", + label: 'Permanences', + to: '#', children: [ - { label: "Listes des permanences", to: "/permanenceslist", rolesAllowed: ["Admin", "Student"] }, - { label: "Mes permanences", to: "/mypermanences", rolesAllowed: ["Admin", "Student"] }, - { label: "Faire l'appel", to: "/permanencesappeal", rolesAllowed: ["Admin", "Student"] }, + { label: 'Listes des permanences', to: '/permanenceslist', rolesAllowed: ['Admin', 'Student'] }, + { label: 'Mes permanences', to: '/mypermanences', rolesAllowed: ['Admin', 'Student'] }, + { label: "Faire l'appel", to: '/permanencesappeal', rolesAllowed: ['Admin', 'Student'] }, ], }, { - label: "Events", - to: "#", + label: 'Events', + to: '#', children: [ - { label: "Shotgun", to: "/shotgun", rolesAllowed: ["Admin", "Student"] }, - { label: "WEI", to: "/wei" }, - { label: "SDI", to: "/sdi" }, - { label: "Repas", to: "/food" }, - { label: "Defis Commissions", to: "/games", rolesAllowed: ["Admin", "Student"] }, + { label: 'Shotgun', to: '/shotgun', rolesAllowed: ['Admin', 'Student'] }, + { label: 'WEI', to: '/wei' }, + { label: 'SDI', to: '/sdi' }, + { label: 'Repas', to: '/food' }, + { label: 'Defis Commissions', to: '/games', rolesAllowed: ['Admin', 'Student'] }, ], }, - { label: "Mon compte", to: "/profil", icon: UsersIcon }, + { label: 'Mon compte', to: '/profil', icon: UsersIcon }, { - label: "Admin", - to: "#", + label: 'Admin', + to: '#', icon: CogIcon, children: [ - { label: "Bus", to: "/admin/bus", rolesAllowed: ["Admin"] }, - { label: "Challenge", to: "/admin/challenge", rolesAllowed: ["Admin", "Arbitre"] }, - { label: "Email", to: "/admin/email", rolesAllowed: ["Admin"] }, - { label: "Events", to: "/admin/events", rolesAllowed: ["Admin"] }, - { label: "Export / Import", to: "/admin/export-import", rolesAllowed: ["Admin"] }, - { label: "Factions", to: "/admin/factions", rolesAllowed: ["Admin", "Respo CE"] }, - { label: "Games", to: "/admin/games", rolesAllowed: ["Admin"] }, - { label: "News", to: "/admin/news", rolesAllowed: ["Admin", "Communication"] }, - { label: "Permanences", to: "/admin/permanences", rolesAllowed: ["Admin", "Respo CE"] }, - { label: "Roles", to: "/admin/roles", rolesAllowed: ["Admin"] }, - { label: "Shotgun", to: "/admin/shotgun", rolesAllowed: ["Admin", "Respo CE"] }, - { label: "Teams", to: "/admin/teams", rolesAllowed: ["Admin", "Respo CE"] }, - { label: "Tentes", to: "/admin/tent", rolesAllowed: ["Admin"] }, - { label: "Users", to: "/admin/users", rolesAllowed: ["Admin"] }, + { label: 'Bus', to: '/admin/bus', rolesAllowed: ['Admin'] }, + { label: 'Challenge', to: '/admin/challenge', rolesAllowed: ['Admin', 'Arbitre'] }, + { label: 'Email', to: '/admin/email', rolesAllowed: ['Admin'] }, + { label: 'Events', to: '/admin/events', rolesAllowed: ['Admin'] }, + { label: 'Export / Import', to: '/admin/export-import', rolesAllowed: ['Admin'] }, + { label: 'Factions', to: '/admin/factions', rolesAllowed: ['Admin', 'Respo CE'] }, + { label: 'Games', to: '/admin/games', rolesAllowed: ['Admin'] }, + { label: 'News', to: '/admin/news', rolesAllowed: ['Admin', 'Communication'] }, + { label: 'Permanences', to: '/admin/permanences', rolesAllowed: ['Admin', 'Respo CE'] }, + { label: 'Roles', to: '/admin/roles', rolesAllowed: ['Admin'] }, + { label: 'Shotgun', to: '/admin/shotgun', rolesAllowed: ['Admin', 'Respo CE'] }, + { label: 'Teams', to: '/admin/teams', rolesAllowed: ['Admin', 'Respo CE'] }, + { label: 'Tentes', to: '/admin/tent', rolesAllowed: ['Admin'] }, + { label: 'Users', to: '/admin/users', rolesAllowed: ['Admin'] }, ], }, { - label: "Déconnexion", - to: "/", - kind: "action", - showWhen: "auth", + label: 'Déconnexion', + to: '/', + kind: 'action', + showWhen: 'auth', onClick: handleLogout, }, { - label: "Se connecter", - to: "/", + label: 'Se connecter', + to: '/', public: true, - showWhen: "guest", + showWhen: 'guest', }, - ]; - const canShowItem = (item: NavItem): boolean => { - if (item.showWhen === "auth") return isAuthenticated; - if (item.showWhen === "guest") return !isAuthenticated; + if (item.showWhen === 'auth') return isAuthenticated; + if (item.showWhen === 'guest') return !isAuthenticated; if (!isAuthenticated) { if (item.public) return true; if (item.children && item.children.length > 0) { - return item.children.some(child => canShowItem(child)); + return item.children.some((child) => canShowItem(child)); } return false; } if (item.rolesAllowed) { - return item.rolesAllowed.some(r => roles.includes(r)); + return item.rolesAllowed.some((r) => roles.includes(r)); } if (item.children && item.children.length > 0) { - return item.children.some(child => canShowItem(child)); + return item.children.some((child) => canShowItem(child)); } return true; }; return ( - + + { + /* Contact Information Warning */ hasContactInformation === false && isAuthenticated && ( +
+

+ ATTENTION : Tu n'as pas complété le formulaire VSS ainsi que tes informations d'urgence. + Merci de le faire au plus vite ! +

+ +
+ ) + } + ); }; -const NavActionItem = ({ - item, - mobile = false, -}: { - item: NavItem; - mobile?: boolean; -}) => { - const base = mobile ? "block py-2 px-4 text-left w-full" : "inline-flex items-center py-2"; +const NavActionItem = ({ item, mobile = false }: { item: NavItem; mobile?: boolean }) => { + const base = mobile ? 'block py-2 px-4 text-left w-full' : 'inline-flex items-center py-2'; return ( - ); }; // Composant MenuItem -const MenuItem = ({ - item, - active = false, - mobile = false, -}: { - item: NavItem; - active?: boolean; - mobile?: boolean; -}) => { - const base = mobile ? "block py-2 px-4" : "inline-flex items-center py-2"; - const activeClass = active - ? "text-yellow-300 font-semibold border-b-2 border-yellow-300" - : "hover:text-yellow-200"; +const MenuItem = ({ item, active = false, mobile = false }: { item: NavItem; active?: boolean; mobile?: boolean }) => { + const base = mobile ? 'block py-2 px-4' : 'inline-flex items-center py-2'; + const activeClass = active ? 'text-yellow-300 font-semibold border-b-2 border-yellow-300' : 'hover:text-yellow-200'; return ( - - {item.icon && ( - - )} + + {item.icon && } {item.label} ); @@ -256,7 +257,7 @@ const Dropdown = ({ canShowItem: (item: NavItem) => boolean; }) => { const [open, setOpen] = useState(false); - const trigger = mobile ? "p-4" : "py-2 cursor-pointer"; + const trigger = mobile ? 'p-4' : 'py-2 cursor-pointer'; return (
@@ -266,8 +267,7 @@ const Dropdown = ({ className={`${trigger} flex items-center justify-between`} aria-expanded={open} aria-controls={`submenu-${item.label}`} - aria-haspopup="menu" - > + aria-haspopup="menu"> {item.icon && } {item.label} @@ -278,19 +278,18 @@ const Dropdown = ({ initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} - className={`absolute bg-white text-black rounded shadow-md z-50 ${mobile ? "static mt-2" : "top-full left-0 mt-1" - }`} - role="menu" - > - {item.children! - .filter(child => canShowItem(child)) - .map(child => ( + className={`absolute bg-white text-black rounded shadow-md z-50 ${ + mobile ? 'static mt-2' : 'top-full left-0 mt-1' + }`} + role="menu"> + {item + .children!.filter((child) => canShowItem(child)) + .map((child) => (
  • + role="menuitem"> {child.label}
  • diff --git a/frontend/src/components/ui/modal.tsx b/frontend/src/components/ui/modal.tsx new file mode 100644 index 0000000..a2f92cf --- /dev/null +++ b/frontend/src/components/ui/modal.tsx @@ -0,0 +1,129 @@ +'use client'; +import { type ReactNode, useEffect } from 'react'; + +import { Button } from '../ui/button'; + +/** + * Displays a modal window. + */ +const Modal = ({ + title = '', + children = '', + buttons = '', + visible = false, + closable = true, + closeOnOutsideClick = false, + onCancel = () => {}, + onOk = () => {}, + className = '', + containerClassName = '', + modalButtonsClassName = '', +}: { + /** Modal window title */ + title?: ReactNode; + /** Modal window content */ + children?: ReactNode; + /** Modal window buttons. + * Pass `null` to hide the footer entirely. + * Pass `""` (default) to get the default Annuler/Ok buttons. */ + buttons?: ReactNode | null; + /** Whether the modal window is visible or not */ + visible: boolean; + /** Whether the modal window is closable or not */ + closable?: boolean; + /** Whether clicking outside the modal closes it */ + closeOnOutsideClick?: boolean; + /** Function called when the user clicks on "Annuler" default button, + * or on the close button, or presses Escape */ + onCancel: () => void; + /** Function called when the user clicks on "Ok" default button */ + onOk?: () => void; + /** An optional class name to add to the modal */ + className?: string; + /** An optional class name to add to the modal container */ + containerClassName?: string; + /** An optional class name to add to the modal buttons container */ + modalButtonsClassName?: string; +}) => { + const buttonsContent = + buttons === null ? null : buttons !== '' ? ( + buttons + ) : ( + <> + + + + ); + + useEffect(() => { + const listener = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel(); + } + }; + + if (visible) { + window.addEventListener('keydown', listener); + } + + return () => { + window.removeEventListener('keydown', listener); + }; + }, [onCancel, visible]); + + return ( +
    +
    + + )} +
    + +
    {children}
    + + {/* Render footer only if buttonsContent is not null */} + {buttonsContent && ( +
    + {buttonsContent} +
    + )} +
    +
    + + ); +}; + +export default Modal; diff --git a/frontend/src/interfaces/user.interface.ts b/frontend/src/interfaces/user.interface.ts index 680dd49..ea26891 100644 --- a/frontend/src/interfaces/user.interface.ts +++ b/frontend/src/interfaces/user.interface.ts @@ -9,3 +9,9 @@ export interface User { contact: string; discord_id: string; } + +export interface UserContactInformation { + userId: number; + urgency_contact_name: string; + urgency_contact_phone: number; +} \ No newline at end of file diff --git a/frontend/src/pages/home.tsx b/frontend/src/pages/home.tsx index 54d9601..44a9259 100644 --- a/frontend/src/pages/home.tsx +++ b/frontend/src/pages/home.tsx @@ -1,14 +1,15 @@ -import { Footer } from "../components/footer"; -import { Infos } from "../components/home/infosSection"; -import { SocialLinks } from "../components/home/socialSection"; -import { Navbar } from "../components/navbar"; +import { Footer } from '../components/footer'; +import { Infos } from '../components/home/infosSection'; +import { SocialLinks } from '../components/home/socialSection'; +import UrgencyModal from '../components/home/urgencyModal'; +import { Navbar } from '../components/navbar'; const HomePage = () => (
    + -
    ); diff --git a/frontend/src/services/requests/user.service.ts b/frontend/src/services/requests/user.service.ts index 80ac24c..1134c17 100644 --- a/frontend/src/services/requests/user.service.ts +++ b/frontend/src/services/requests/user.service.ts @@ -1,4 +1,4 @@ -import { type User } from '../../interfaces/user.interface'; +import { type User, type UserContactInformation } from '../../interfaces/user.interface'; import api from '../api'; export const getPermission = (): string | null => { @@ -41,11 +41,23 @@ export const getUsersByPermission = async () => { return users; } +export const getUserContactInformation = async (userId: number) => { + const response = await api.get(`/user/admin/getusercontactinformation/${userId}`); + const users: UserContactInformation = response.data.data; + return users; +}; + export const getCurrentUser = async () => { const res = await api.get("/user/user/me"); return res.data.data; }; +export const getCurrentUserContactInformation = async () => { + const response = await api.get(`/user/user/getusercontactinformation`); + const users: UserContactInformation = response.data.data; + return users; +}; + export const updateCurrentUser = async (data: Partial) => { const response = await api.patch("/user/user/me", data); return response.data