diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9545791 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Example environment variables (copy to .env and fill values) +SUPABASE_URL= +SUPABASE_KEY= +SECRET_KEY=change_this_secret +BACKEND_URL=http://localhost:8000 +FRONTEND_URL=http://localhost:3000 +FRONTEND_URL_DEPLOYED= + +# SMTP (for email sending) +SMTP_SERVER=smtp.example.com +SMTP_PORT=465 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_EMAIL= +SMTP_FROM_NAME=CreaLab +ADMIN_EMAIL=admin@crealab.com + +# Google Calendar (optional) +GOOGLE_CALENDAR_SYNC_ENABLED=false +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REFRESH_TOKEN= +GOOGLE_CALENDAR_ID=primary +GOOGLE_CALENDAR_TIMEZONE=Europe/Paris diff --git a/.gitignore b/.gitignore index e689c6d..8f263ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ +/.env +/.env.local +/.env.*.local +node_modules/ +frontend/node_modules/ +backend/__pycache__/ +**/__pycache__/ +*.pyc +frontend/build/ +dist/ +.venv/ +.idea/ +.vscode/ +*.log # Node modules node_modules/ npm-debug.log* diff --git a/backend/models/event.py b/backend/models/event.py index 436edb5..0c3f536 100644 --- a/backend/models/event.py +++ b/backend/models/event.py @@ -1,18 +1,20 @@ from pydantic import BaseModel from datetime import datetime +from typing import Optional class EventCreate(BaseModel): id: str title: str user: str + email: Optional[str] = None start: datetime startStr: str end: datetime endStr: str duration: str color: str - id_card: str + id_card: Optional[str] = None class Event(EventCreate): diff --git a/backend/models/profile.py b/backend/models/profile.py index bc494ac..88de560 100644 --- a/backend/models/profile.py +++ b/backend/models/profile.py @@ -1,12 +1,13 @@ from pydantic import BaseModel +from typing import Optional from .user import UserRole class ProfileData(BaseModel): - card_id: str + card_id: Optional[str] = None first_name: str last_name: str email: str - password: str + password: Optional[str] = None role: UserRole admin: bool = False \ No newline at end of file diff --git a/backend/routes/cards.py b/backend/routes/cards.py index 8d4044f..8c8cd04 100644 --- a/backend/routes/cards.py +++ b/backend/routes/cards.py @@ -42,9 +42,15 @@ async def get_card(card_data: CardScan): @router.get("/check-card/{card_id}") def check_existing_card(card_id: str): + # Try to find by card_id first result = supabase.table("CreaLab_visitors").select("*").eq("id_card", card_id).execute() exists = len(result.data) > 0 if exists: return {"exists": True, "data": result.data} else: + # Fallback: also try to find by email (card_id could be an email) + result = supabase.table("CreaLab_visitors").select("*").eq("email", card_id).execute() + exists = len(result.data) > 0 + if exists: + return {"exists": True, "data": result.data} return {"exists": False} \ No newline at end of file diff --git a/backend/routes/events.py b/backend/routes/events.py index f00191f..885cea9 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -80,14 +80,20 @@ def get_user_info_by_card_id(card_id: str) -> tuple: def send_notification_email(event_data: dict, action: str) -> bool: try: - card_id = event_data.get("id_card") - if not card_id: - logging.warning("No card_id found for event, cannot send notification email") - return False - - user_email, user_name = get_user_info_by_card_id(card_id) + # Try to get user email from event data first + user_email = event_data.get("email") + user_name = event_data.get("user", "Utilisateur") + + # If email not in event data, try to get it from card_id (fallback) if not user_email: - logging.warning(f"No email found for card {card_id}, cannot send notification email") + card_id = event_data.get("id_card") + if card_id: + user_email, name = get_user_info_by_card_id(card_id) + if name: + user_name = name + + if not user_email: + logging.warning("No email found for event, cannot send notification email") return False if action == "accept": @@ -107,11 +113,18 @@ def sync_event_to_google_calendar(event_data: dict) -> None: try: event_payload = dict(event_data) - card_id = event_data.get("id_card") - if card_id: - creator_email, _ = get_user_info_by_card_id(card_id) - if creator_email: - event_payload["creator_email"] = creator_email + # Try to get creator email from event data first + creator_email = event_data.get("email") + + # If email not in event data, try to get it from card_id (fallback) + if not creator_email: + card_id = event_data.get("id_card") + if card_id: + creator_email, _ = get_user_info_by_card_id(card_id) + + # Add creator email to event payload if available + if creator_email: + event_payload["creator_email"] = creator_email result = sync_validated_event_to_admin_calendar(event_payload) if result.get("synced"): diff --git a/backend/routes/users.py b/backend/routes/users.py index 11fa679..f168a5c 100644 --- a/backend/routes/users.py +++ b/backend/routes/users.py @@ -64,6 +64,7 @@ def login_user(request: Request, data: LoginData): card_id = user.get("id_card") scanned_card_id = data.scanned_card_id.strip() if data.scanned_card_id else None + # If a card is scanned, associate it with the account (optional step to make login faster) if scanned_card_id: validate_card_context(latest_card, scanned_card_id) @@ -83,16 +84,10 @@ def login_user(request: Request, data: LoginData): supabase.table("CreaLab_visitors").update({"id_card": scanned_card_id}).eq("email", data.email).execute() card_id = scanned_card_id + latest_card["id"] = card_id + latest_card["ts"] = datetime.utcnow() - if _is_temporary_card(card_id): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Scannez une carte pour finaliser la connexion" - ) - - latest_card["id"] = card_id - latest_card["ts"] = datetime.utcnow() - + # Allow login even without a valid card - card scanning is optional and used for faster identification return { "authenticated": True, "card_id": card_id @@ -114,7 +109,7 @@ def submit_data(request: Request, data: ProfileData): if existing_email.data: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Un compte existe déjà avec cet email") - normalized_card_id = data.card_id.strip() + normalized_card_id = data.card_id.strip() if data.card_id else NO_CARD_PLACEHOLDER if normalized_card_id == NO_CARD_PLACEHOLDER: normalized_card_id = f"{TEMP_CARD_PREFIX}{uuid4().hex[:12]}" else: @@ -125,7 +120,7 @@ def submit_data(request: Request, data: ProfileData): if not is_school_email(data.email): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Adresse email invalide: domaines autorises @devinci.fr ou @edu.vinci.fr" + detail="Adresse email invalide: domaines autorises @devinci.fr ou @edu.devinci.fr" ) supabase.table("CreaLab_visitors").insert({ @@ -142,26 +137,31 @@ def submit_data(request: Request, data: ProfileData): @router.post("/update-profile") def update_profile(request: Request, data: ProfileData): - logging.info("Updating profile for card: %s", data.card_id) + logging.info("Updating profile for email: %s", data.email) validate_origin(request, FRONTEND_URLS) - validate_card_context(latest_card, data.card_id) - + + # Use email as primary identifier for updating profile + # This works whether user logged in with card or without supabase.table("CreaLab_visitors").update({ "first_name": data.first_name, "last_name": data.last_name, - "email": data.email, "role": data.role.value - }).eq("id_card", data.card_id).execute() - return {"message": f"Profil pour la carte {data.card_id} mis à jour avec succès"} + }).eq("email", data.email).execute() + + return {"message": f"Profil pour {data.email} mis à jour avec succès"} @router.get("/get-profile/{card_id}") def get_profile(request: Request, card_id: str): validate_origin(request, FRONTEND_URLS) - validate_card_context(latest_card, card_id) - + + # Try to get profile by card_id first (for backward compatibility) result = supabase.table("CreaLab_visitors").select("*").eq("id_card", card_id).execute() if len(result.data) > 0: return {"found": True, "data": result.data[0]} else: + # Fallback: also search by email (card_id could be an email in some cases) + result = supabase.table("CreaLab_visitors").select("*").eq("email", card_id).execute() + if len(result.data) > 0: + return {"found": True, "data": result.data[0]} return {"found": False} \ No newline at end of file diff --git a/backend/utils/auth.py b/backend/utils/auth.py index a2151bf..4ab0178 100644 --- a/backend/utils/auth.py +++ b/backend/utils/auth.py @@ -1,7 +1,7 @@ from datetime import datetime -ALLOWED_SCHOOL_EMAIL_DOMAINS = ("@devinci.fr", "@edu.vinci.fr") +ALLOWED_SCHOOL_EMAIL_DOMAINS = ("@devinci.fr", "@edu.devinci.fr") def _normalize_origin(value: str) -> str: diff --git a/frontend/src/App.css b/frontend/src/App.css index 42e2ac9..5c44d0f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -21,7 +21,14 @@ display: flex; justify-content: center; align-items: center; - filter: blur(3px); + filter: blur(2px); +} + +.inscription-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; } .login-container, .waiting-container { @@ -32,7 +39,7 @@ background-color: #fff; padding: 20px; border-radius: 8px; - gap: 16px; + gap : 16px; } .login-container h2, .waiting-container h2 { @@ -43,7 +50,7 @@ .login-container p, .waiting-container p { font-size: 1.25em; font-weight: 500; - margin-top: 12px; + margin-bottom: 12px; color: #333; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cbbd069..fb74f6c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import Calendar from './pages/Calendar/Calendar'; import Inscription from './components/Inscription/Inscription'; import Connexion from './components/Connexion/Connexion'; import { setCardScanCallback } from './services/cardScanListener'; +import { getApiUrl } from './services/api'; import Bouton from './components/Bouton/Bouton'; type AppState = 'waiting' | 'login' | 'inscription' | 'calendar' | 'cardNotFound' | 'linkLogin'; @@ -28,7 +29,7 @@ function App() { useEffect(() => { const checkExistingCard = async (id: string) => { try { - const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000'; + const apiUrl = getApiUrl(); const response = await fetch(`${apiUrl}/check-card/${id}`); const data = await response.json(); if (data.exists) { @@ -118,8 +119,10 @@ function App() { setAppState('calendar'); }} /> -

Vous n'êtes pas encore inscrit ?

- setAppState('inscription')} label="S'inscrire" /> +
+

Vous n'êtes pas encore inscrit ?

+ setAppState('inscription')} label="S'inscrire" /> +
); diff --git a/frontend/src/components/Connexion/Connexion.css b/frontend/src/components/Connexion/Connexion.css index ac01914..0542d46 100644 --- a/frontend/src/components/Connexion/Connexion.css +++ b/frontend/src/components/Connexion/Connexion.css @@ -18,7 +18,7 @@ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; - width: min(640px, 100%); + width: 100%; } .connexion_form .form_email { @@ -56,7 +56,6 @@ @media (max-width: 768px) { .connexion_container { - width: calc(100vw - 48px); padding: 24px; } diff --git a/frontend/src/components/Connexion/Connexion.tsx b/frontend/src/components/Connexion/Connexion.tsx index a32ce55..50063dd 100644 --- a/frontend/src/components/Connexion/Connexion.tsx +++ b/frontend/src/components/Connexion/Connexion.tsx @@ -47,7 +47,7 @@ const Connexion = ({ onLoginSuccess, scannedCardId, onBack }: ConnexionProps) => const data = await response.json(); - if (!response.ok || !data?.authenticated || !data?.card_id) { + if (!response.ok || !data?.authenticated) { throw new Error(data?.detail || "Identifiants invalides"); } diff --git a/frontend/src/components/CreateCustomEvent/CreateCustomEvent.tsx b/frontend/src/components/CreateCustomEvent/CreateCustomEvent.tsx index 41eba4a..554f9b5 100644 --- a/frontend/src/components/CreateCustomEvent/CreateCustomEvent.tsx +++ b/frontend/src/components/CreateCustomEvent/CreateCustomEvent.tsx @@ -47,7 +47,8 @@ const CreateCustomEvent = ({ openModal, setOpenModal, currentUser, onEventSave } duration: String(end.getTime() - start.getTime()), color, user: currentUser.first_name + " " + currentUser.last_name, - id_card: currentUser.id_card || "", + email: currentUser.email, + id_card: currentUser.id_card, accepted: false, }; diff --git a/frontend/src/components/Inscription/Inscription.css b/frontend/src/components/Inscription/Inscription.css index 935d737..bb15d6f 100644 --- a/frontend/src/components/Inscription/Inscription.css +++ b/frontend/src/components/Inscription/Inscription.css @@ -17,6 +17,7 @@ .inscription_form { display: grid; grid-template-columns: 1fr 1fr; + width: 100%; gap: 20px; } diff --git a/frontend/src/components/Inscription/Inscription.tsx b/frontend/src/components/Inscription/Inscription.tsx index e30acb2..dc26711 100644 --- a/frontend/src/components/Inscription/Inscription.tsx +++ b/frontend/src/components/Inscription/Inscription.tsx @@ -33,7 +33,7 @@ const Inscription = ({card_id}: InscriptionInterface) => { setFormError(""); if (!isSchoolEmail(email)) { - setEmailError("L'email doit se terminer par @devinci.fr ou @edu.vinci.fr."); + setEmailError("L'email doit se terminer par @devinci.fr ou @edu.devinci.fr."); return; } @@ -78,8 +78,8 @@ const Inscription = ({card_id}: InscriptionInterface) => { return; } - if (error instanceof Error && error.message.includes("@devinci.fr ou @edu.vinci.fr")) { - setEmailError("L'email doit se terminer par @devinci.fr ou @edu.vinci.fr."); + if (error instanceof Error && error.message.includes("@devinci.fr ou @edu.devinci.fr")) { + setEmailError("L'email doit se terminer par @devinci.fr ou @edu.devinci.fr."); return; } diff --git a/frontend/src/hooks/useCalendarApi.ts b/frontend/src/hooks/useCalendarApi.ts index 59a6527..5a8c447 100644 --- a/frontend/src/hooks/useCalendarApi.ts +++ b/frontend/src/hooks/useCalendarApi.ts @@ -47,6 +47,7 @@ export const useCalendarApi = () => { borderColor: displayColor, extendedProps: { user: event.user, + email: event.email, duration: event.duration, id_card: event.id_card, accepted: event.accepted diff --git a/frontend/src/pages/Calendar/Calendar.tsx b/frontend/src/pages/Calendar/Calendar.tsx index c2d38db..8394cc9 100644 --- a/frontend/src/pages/Calendar/Calendar.tsx +++ b/frontend/src/pages/Calendar/Calendar.tsx @@ -5,7 +5,7 @@ import timeGridPlugin from "@fullcalendar/timegrid"; import interactionPlugin from "@fullcalendar/interaction"; import { EventContentArg } from "@fullcalendar/core"; import frLocale from '@fullcalendar/core/locales/fr'; -import { io, Socket } from "socket.io-client"; +import getSocket from "../../services/socketClient"; import "./CalendarComponent.css"; import "./FullCalendar.css"; import Sidebar from "../../layout/sidebar/Sidebar"; @@ -39,37 +39,25 @@ const Calendar = ({ card_id, setIsAdmin, setRefreshEvents }: CalendarEvent) => { }, [userData, setIsAdmin]); useEffect(() => { - let socket: Socket | null = null; - - const initSocket = () => { - const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000'; - - socket = io(apiUrl, { - transports: ["websocket"] - }); - - socket.on("connect", () => { - console.log("Calendar socket connected", socket?.id); - }); - - socket.on("events_updated", (data: { action: string; event?: CalendarEventData; event_id?: string }) => { - console.log(`Event ${data.action}:`, data); - fetchEvents(); - }); - - socket.on("disconnect", () => { - console.log("Calendar socket disconnected"); - }); + const socket = getSocket(); + + const onEventsUpdated = (data: { action: string; event?: CalendarEventData; event_id?: string }) => { + console.log(`Event ${data.action}:`, data); + fetchEvents(); }; - if (card_id) { - initSocket(); - } + socket.on("connect", () => { + console.log("Calendar socket connected", socket.id); + }); + + socket.on("events_updated", onEventsUpdated); + + socket.on("disconnect", () => { + console.log("Calendar socket disconnected"); + }); return () => { - if (socket) { - socket.disconnect(); - } + socket.off("events_updated", onEventsUpdated); }; }, [card_id, fetchEvents]); @@ -78,6 +66,7 @@ const Calendar = ({ card_id, setIsAdmin, setRefreshEvents }: CalendarEvent) => { id: info.event.id, title: info.event.title, user: (info.event.extendedProps.user as string) || 'Unknown', + email: userData?.email, start: info.event.start || new Date(), startStr: info.event.startStr, end: info.event.end || info.event.start || new Date(), @@ -88,7 +77,7 @@ const Calendar = ({ card_id, setIsAdmin, setRefreshEvents }: CalendarEvent) => { accepted: false }; saveEvent(eventData); - }, [card_id, saveEvent]); + }, [card_id, userData?.email, saveEvent]); const renderEventContent = useCallback((eventInfo: EventContentArg) => { const onDeleteClick = (event: React.MouseEvent) => { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..c4466a7 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,7 @@ +export const getApiUrl = (): string => { + return (process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || 'http://localhost:8000'); +}; + +export const getHeaders = (): Record => ({ + 'Content-Type': 'application/json' +}); diff --git a/frontend/src/services/cardScanListener.ts b/frontend/src/services/cardScanListener.ts index f70ab53..07b7a94 100644 --- a/frontend/src/services/cardScanListener.ts +++ b/frontend/src/services/cardScanListener.ts @@ -1,20 +1,17 @@ -import { io, Socket } from "socket.io-client"; +import { getSocket } from './socketClient'; let previousId: string | null = null; let onCardScanned: ((id: string) => void) | null = null; -let socket: Socket | null = null; export const setCardScanCallback = (callback: (id: string) => void) => { onCardScanned = callback; }; const initSocket = () => { - if (socket) return; - const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000'; - socket = io(apiUrl, { transports: ["websocket"] }); + const socket = getSocket(); socket.on("connect", () => { - console.log("Card socket connected", socket?.id); + console.log("Card socket connected", socket.id); }); socket.on("card_scanned", (cardId: string) => { diff --git a/frontend/src/services/socketClient.ts b/frontend/src/services/socketClient.ts new file mode 100644 index 0000000..2ce6a93 --- /dev/null +++ b/frontend/src/services/socketClient.ts @@ -0,0 +1,14 @@ +import { io, Socket } from "socket.io-client"; +import { getApiUrl } from './api'; + +let socket: Socket | null = null; + +export const getSocket = (): Socket => { + if (!socket) { + const apiUrl = getApiUrl(); + socket = io(apiUrl, { transports: ["websocket"] }); + } + return socket; +}; + +export default getSocket; diff --git a/frontend/src/types/globalTypes.ts b/frontend/src/types/globalTypes.ts index 0b571b5..6a34ebd 100644 --- a/frontend/src/types/globalTypes.ts +++ b/frontend/src/types/globalTypes.ts @@ -3,7 +3,8 @@ export interface UserData { last_name: string; email: string; role: string; - [key: string]: string; + id_card?: string; + [key: string]: string | undefined; } export interface CalendarEvent { card_id: string; @@ -24,13 +25,14 @@ export interface CalendarEventData { id: string; title: string; user: string; + email?: string; start: Date; startStr: string; end: Date; endStr: string; duration: string; color: string; - id_card: string; + id_card?: string; accepted: boolean; } @@ -43,8 +45,9 @@ export interface FormattedCalendarEvent { borderColor: string; extendedProps: { user: string; + email?: string; duration: string; - id_card: string; + id_card?: string; accepted: boolean; }; } @@ -53,13 +56,14 @@ export interface BackendEvent { id: string; title: string; user: string; + email?: string; start: string; startStr: string; end: string; endStr: string; duration: string; color: string; - id_card: string; + id_card?: string; accepted: boolean; } diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts index e3edc30..fab3f5f 100644 --- a/frontend/src/utils/auth.ts +++ b/frontend/src/utils/auth.ts @@ -1,4 +1,4 @@ -const ALLOWED_SCHOOL_EMAIL_DOMAINS = ["@devinci.fr", "@edu.vinci.fr"] as const; +const ALLOWED_SCHOOL_EMAIL_DOMAINS = ["@devinci.fr", "@edu.devinci.fr"] as const; export const isSchoolEmail = (emailValue: string): boolean => { const normalizedEmail = emailValue.trim().toLowerCase(); diff --git a/mettre le s inscrire et.txt b/mettre le s inscrire et.txt new file mode 100644 index 0000000..3f4e52c --- /dev/null +++ b/mettre le s inscrire et.txt @@ -0,0 +1,34 @@ +mettre le s'inscrire et le connexion à coté +cacher id card et role +changer le log apres inscription +corriger les fautes de frappes (dans le log apres inscription) +ajouterelemnts pour clic et drag les evenements (ou clicquer sur calendrier directement pour creer un event) +event custom --> ajouter descriptif à l'evenemtns et possibilité de selectionner plusiseurs tags (genre impression, peintures, etc..) / Laisser vite les créneax de base (sinon l'event se place a l'heure meme en validant) +Separer les impressions perso / école dans les events pre fait (voir si atereil donner par l'utilisateur ou le crealab) +Si @devin alors staff / si edu alors etudiant + +TODO projet (priorisé) + +[UI / Auth] +- Aligner les boutons « Inscription » et « Connexion » sur la même ligne. +- Masquer les champs sensibles (ID card, rôle) dans les formulaires/écrans publics. +- Mettre à jour le message affiché après inscription. +- Relire et corriger les libellés/messages pour supprimer les fautes. + +[Calendrier / Création d’événements] +- Ajouter la création d’événement par clic direct sur le calendrier. +- Ajouter le glisser-déposer d’événements. +- Conserver les créneaux par défaut lors de la validation (éviter placement à l’heure courante). + +[Modèle d’événement] +- Ajouter un champ « description » pour les événements personnalisés. +- Permettre la sélection multiple de tags (ex. impression, peinture, etc.). + +[Catégorisation] +- Séparer clairement les impressions « perso » vs « école » dans les événements prédéfinis. +- Ajouter la source du matériel (utilisateur ou CreaLab) et l’afficher dans l’événement. + +[Qualité / Validation] +- Vérifier les règles de visibilité des champs selon le rôle. +- Tester les parcours complets: inscription, connexion, création événement, édition, drag & drop. +- Ajouter une passe QA finale sur l’UX et les textes FR. \ No newline at end of file diff --git a/package.json b/package.json index eeb23a1..948616f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crealabvisitors", - "version": "2.0.0", + "version": "2.1.0", "description": "Une petite application de gestion des visiteurs pour l'espace CreaLab. Suit les visites, stocke les métadonnées de base et fournit une interface/API simple pour consulter les journaux des visiteurs.", "homepage": "https://github.com/IIM-CDI/CreaLabVisitors#readme", "bugs": {