diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 81f1964..e8ce7cf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,26 +11,90 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout repository + - name: 📦 Checkout repository uses: actions/checkout@v3 - - name: Setup SSH agent + - name: 🔐 Setup SSH agent uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - name: Setup known_hosts + - name: 🧾 Setup known_hosts run: | mkdir -p ~/.ssh echo "${{ secrets.VPS_KNOWN_HOST }}" > ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts - - name: Deploy + - name: 🚀 Deploy to VPS run: | if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "ENVIRONMENT=PRODUCTION" >> $GITHUB_ENV echo "🚀 Deploying PRODUCTION" - ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "bash /home/ubuntu/deploy-triptogether.sh" + ssh -o StrictHostKeyChecking=yes \ + ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \ + "bash /home/ubuntu/deploy-triptogether.sh" else + echo "ENVIRONMENT=STAGING" >> $GITHUB_ENV echo "🧪 Deploying STAGING" - ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "bash /home/ubuntu/deploy-triptogether-staging.sh" + ssh -o StrictHostKeyChecking=yes \ + ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \ + "bash /home/ubuntu/deploy-triptogether-staging.sh" fi + + ############################################################ + # ✅ SUCCESS NOTIFICATION (FULLY SECURED) + ############################################################ + - name: ✅ Telegram Success Notification + if: success() + env: + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + SAFE_COMMIT=$(printf "%s" "$COMMIT_MESSAGE" \ + | sed -e 's/&/\&/g' \ + -e 's//\>/g') + + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "parse_mode=HTML" \ + --data-urlencode "text=✅ ${ENVIRONMENT} Deploy SUCCESS + + 📦 Repo: ${{ github.repository }} + 🌿 Branch: ${{ github.ref_name }} + 👤 Author: ${{ github.actor }} + 📝 Commit: ${SAFE_COMMIT} + 🕒 Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC') + + 🔎 View Logs" + + ############################################################ + # ❌ FAILURE NOTIFICATION (FULLY SECURED) + ############################################################ + - name: ❌ Telegram Failure Notification + if: failure() + env: + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + SAFE_COMMIT=$(printf "%s" "$COMMIT_MESSAGE" \ + | sed -e 's/&/\&/g' \ + -e 's//\>/g') + + curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "parse_mode=HTML" \ + --data-urlencode "text=❌ ${ENVIRONMENT} Deploy FAILED + + 📦 Repo: ${{ github.repository }} + 🌿 Branch: ${{ github.ref_name }} + 👤 Author: ${{ github.actor }} + 📝 Commit: ${SAFE_COMMIT} + 🕒 Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC') + + ⚠️ Check immediately. + + 🔎 View Logs" diff --git a/client/src/components/AddExpenseForm.tsx b/client/src/components/AddExpenseForm.tsx index cd72df1..19adc0a 100644 --- a/client/src/components/AddExpenseForm.tsx +++ b/client/src/components/AddExpenseForm.tsx @@ -16,25 +16,31 @@ type AddExpenseFormProps = { function AddExpenseForm({ tripId, members, onSuccess }: AddExpenseFormProps) { const { auth } = useAuth(); + const [title, setTitle] = useState(""); const [amount, setAmount] = useState(""); const [categoryId, setCategoryId] = useState(""); const [paidBy, setPaidBy] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setError(null); if (!auth?.token) { - console.error("Utilisateur non authentifié"); + setError("Utilisateur non authentifié."); return; } if (!title || !amount || !categoryId || !paidBy) { - console.error("Champs manquants"); + setError("Veuillez remplir tous les champs."); return; } try { + setIsSubmitting(true); + const response = await fetch( `${import.meta.env.VITE_API_URL}/api/expenses/${tripId}`, { @@ -58,59 +64,100 @@ function AddExpenseForm({ tripId, members, onSuccess }: AddExpenseFormProps) { throw new Error(errorData.message || "Erreur création dépense"); } + // Reset + setTitle(""); + setAmount(""); + setCategoryId(""); + setPaidBy(""); + onSuccess(); - } catch (error) { - console.error(error); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("Une erreur est survenue."); + } + } finally { + setIsSubmitting(false); } }; return ( -
-

Ajouter une dépense

- - setTitle(e.target.value)} - required - /> - - setAmount(e.target.value)} - required - /> - - - - - - + +

Ajouter une dépense

+ + {error && ( +
+ {error} +
+ )} + + {/* Titre */} +
+ + setTitle(e.target.value)} + required + /> +
+ + {/* Montant */} +
+ + setAmount(e.target.value)} + required + /> +
+ + {/* Catégorie */} +
+ + +
+ + {/* Payé par */} +
+ + +
+ +
); } diff --git a/client/src/components/Guests.tsx b/client/src/components/Guests.tsx index 6062d64..adc0f98 100644 --- a/client/src/components/Guests.tsx +++ b/client/src/components/Guests.tsx @@ -1,94 +1,88 @@ import type { Guest } from "../types/invitationType"; import "../pages/styles/Guests.css"; -type GuestsProps = - | { - title: string; - invited: Guest[]; - type: "attendees"; - delete?: (invitation: Guest) => void; - } - | { - title: string; - invited: Guest[]; - type: "others"; - delete?: (invitation: Guest) => void; - }; - -function Guests(props: GuestsProps) { - const { title, invited } = props; +type GuestsProps = { + title: string; + invited: Guest[]; + type: "attendees" | "others"; + delete?: (invitation: Guest) => void; +}; +function Guests({ title, invited, type, delete: onDelete }: GuestsProps) { return (
-

- {title} ({invited.length}) +

+ {title} ({invited.length})

-
); diff --git a/client/src/components/Modal.tsx b/client/src/components/Modal.tsx index a85a716..be680e3 100644 --- a/client/src/components/Modal.tsx +++ b/client/src/components/Modal.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { ReactNode } from "react"; import "../pages/styles/Modal.css"; @@ -9,36 +9,67 @@ type ModalProps = { }; function Modal({ isOpen, onClose, children }: ModalProps) { + const modalRef = useRef(null); + + // 🔒 Bloque le scroll du body useEffect(() => { - if (isOpen) document.body.style.overflow = "hidden"; + if (isOpen) { + document.body.style.overflow = "hidden"; + modalRef.current?.focus(); + } + return () => { document.body.style.overflow = ""; }; }, [isOpen]); + // ⌨️ Fermeture via Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleKeyDown); + } + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); + if (!isOpen) return null; return (
{ if (e.target === e.currentTarget) { onClose(); } }} - tabIndex={-1} onKeyDown={(e) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { + onClose(); + } }} > -
+
{children}
diff --git a/client/src/components/NavTabs.tsx b/client/src/components/NavTabs.tsx index 93c8c5d..d4bac65 100644 --- a/client/src/components/NavTabs.tsx +++ b/client/src/components/NavTabs.tsx @@ -1,30 +1,18 @@ -import { NavLink, useLocation, useParams } from "react-router"; +import { NavLink, useParams } from "react-router"; import "../pages/styles/NavTabs.css"; const NavTabs = () => { const { id } = useParams<{ id: string }>(); - const location = useLocation(); - // Helper to check if a path matches the current location - const isActive = (path: string) => { - // Current path without trailing slash - const currentPath = location.pathname.endsWith("/") - ? location.pathname.slice(0, -1) - : location.pathname; - - // Target path without trailing slash - const targetPath = path.endsWith("/") ? path.slice(0, -1) : path; - - return currentPath === targetPath; - }; + if (!id) return null; return ( -
+
- `tab ${isActive(id ? `/trip/${id}` : "/") ? "active" : ""}` - } + to={`/trip/${id}`} + end + className={({ isActive }) => `tab ${isActive ? "active" : ""}`} + aria-label="Récapitulatif du voyage" > Récap Voyage @@ -32,11 +20,11 @@ const NavTabs = () => { Récap + - `tab ${isActive(id ? `/trip/${id}/steps` : "/") ? "active" : ""}` - } + to={`/trip/${id}/steps`} + className={({ isActive }) => `tab ${isActive ? "active" : ""}`} + aria-label="Destinations" > Destinations @@ -46,10 +34,9 @@ const NavTabs = () => { - `tab ${isActive(id ? `/trip/${id}/invitations` : "/") ? "active" : ""}` - } + to={`/trip/${id}/invitations`} + className={({ isActive }) => `tab ${isActive ? "active" : ""}`} + aria-label="Membres" > Membres @@ -59,30 +46,29 @@ const NavTabs = () => { - `tab ${isActive(id ? `/trip/${id}/budget` : "/") ? "active" : ""}` - } + to={`/trip/${id}/budget`} + className={({ isActive }) => `tab ${isActive ? "active" : ""}`} + aria-label="Budget" > Budget - + Budget -
+
- Disponible prochaînement - + Disponible prochainement + Maps
-
+
- Disponible prochaînement - + Disponible prochainement + Chat
diff --git a/client/src/components/TripInfos.tsx b/client/src/components/TripInfos.tsx index 7407fc6..a6fef9d 100644 --- a/client/src/components/TripInfos.tsx +++ b/client/src/components/TripInfos.tsx @@ -1,9 +1,9 @@ +import { useState } from "react"; import TripCard from "../pages/TripCard"; import TripInvitation from "../pages/TripInvitation"; import type { TheTrip } from "../types/tripType"; import Modal from "./Modal"; import "../pages/styles/TripInfos.css"; -import { useState } from "react"; type TripInfosProps = { trip: TheTrip | null; @@ -11,15 +11,12 @@ type TripInfosProps = { function TripInfos({ trip }: TripInfosProps) { if (!trip) return null; + const tripId = trip.id; const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); - const openInviteModal = () => { - setIsInviteModalOpen(true); - }; + const openInviteModal = () => setIsInviteModalOpen(true); + const closeInviteModal = () => setIsInviteModalOpen(false); - const closeInviteModal = () => { - setIsInviteModalOpen(false); - }; return ( <>
- {/*
*/} -
-
- {trip && ( + +
+

+ {trip.title ?? `${trip.city}, ${trip.country}`} +

+
+
- )} -
+
+
+ - {trip && ( - - )} + ); } + export default TripInfos; diff --git a/client/src/pages/Invitations.tsx b/client/src/pages/Invitations.tsx index ec324b9..7aea115 100644 --- a/client/src/pages/Invitations.tsx +++ b/client/src/pages/Invitations.tsx @@ -25,115 +25,88 @@ type InvitationsResponse = function Invitations() { const { id } = useParams(); const tripId = Number(id); + const navigate = useNavigate(); const [trip, setTrip] = useState(null); - const [mytrip, setmyTrip] = useState(null); const [attendees, setAttendees] = useState([]); const [otherInvitations, setOtherInvitations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [deleteInvitation, setdeleteInvitation] = useState(null); + const [deleteInvitation, setDeleteInvitation] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - const navigate = useNavigate(); useEffect(() => { if (!tripId) { navigate("/", { state: { - toast: { - type: "error", - message: "Voyage invalide", - }, + toast: { type: "error", message: "Voyage invalide" }, }, }); return; } - setLoading(true); - setError(null); - fetch(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}`) - - .then(async (response) => { - if (!response.ok) { - if (response.status === 401) { - toast.error("Veuillez vous connecter pour accéder à ce voyage."); - return; - } + const controller = new AbortController(); + const { signal } = controller; + + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const [tripResp, invitationsResp] = await Promise.all([ + fetch(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}`, { + signal, + }), + fetch( + `${import.meta.env.VITE_API_URL}/api/trips/${tripId}/invitations`, + { signal }, + ), + ]); + + if (!tripResp.ok) { throw new Error("Erreur chargement voyage"); } - const data = await response.json(); - setmyTrip(data); - }) - .catch((err) => { - console.error(err); - toast.error("Impossible de charger le voyage"); - }); - fetch(`${import.meta.env.VITE_API_URL}/api/trips/${tripId}/invitations`) - .then(async (response) => { - const result: InvitationsResponse = await response.json(); - - if (response.status === 400) { - navigate("/", { - state: { - toast: { - type: "error", - message: "Requête invalide", - }, - }, - }); - return; - } - - if (response.status === 403) { - navigate("/", { - state: { - toast: { - type: "error", - message: "Accès non autorisé", - }, - }, - }); - return; - } - - if (!response.ok) { + if (!invitationsResp.ok) { throw new Error("Erreur chargement invitations"); } - if (!("trip" in result)) { - setError("Données invitations invalides."); + const tripData: TheTrip = await tripResp.json(); + const invitationsResult: InvitationsResponse = + await invitationsResp.json(); + + if (!("trip" in invitationsResult)) { + setError("Données invalides."); return; } - const { trip, invitations } = result; - setTrip(trip); + const { invitations } = invitationsResult; + + setTrip(tripData); const creator: Guest = { - id: trip.user_id || 0, - name: `${trip.owner_firstname ?? ""} ${trip.owner_lastname ?? ""}`.trim(), + id: tripData.user_id ?? 0, + name: `${tripData.owner_firstname ?? ""} ${ + tripData.owner_lastname ?? "" + }`.trim(), avatarUrl: null, addedAt: null, role: "organisateur", }; - const acceptedInvitations = invitations.filter( - (invitation) => invitation.status === "accepted", - ); - - const acceptedGuests: Guest[] = acceptedInvitations.map((inv) => ({ - id: inv.user_id, - name: `${inv.invited_firstname} ${inv.invited_lastname}`, - avatarUrl: null, - addedAt: inv.created_at, - role: "membre", - })); - - const attendees: Guest[] = [creator, ...acceptedGuests]; + const acceptedGuests: Guest[] = invitations + .filter((inv) => inv.status === "accepted") + .map((inv) => ({ + id: inv.user_id, + name: `${inv.invited_firstname} ${inv.invited_lastname}`, + avatarUrl: null, + addedAt: inv.created_at, + role: "membre", + })); - const otherInvitationsGuests: Guest[] = invitations - .filter((invitation) => invitation.status !== "accepted") + const otherGuests: Guest[] = invitations + .filter((inv) => inv.status !== "accepted") .map((inv) => ({ id: inv.user_id, name: `${inv.invited_firstname} ${inv.invited_lastname}`, @@ -143,72 +116,63 @@ function Invitations() { lastReminderAt: null, })); - setAttendees(attendees); - setOtherInvitations(otherInvitationsGuests); - }) - .catch((err) => { - console.error("Erreur fetch invitations:", err); - setError("Impossible de charger les invitations."); - }) - .finally(() => { - setLoading(false); - }); + setAttendees([creator, ...acceptedGuests]); + setOtherInvitations(otherGuests); + } catch (err) { + if (!signal.aborted) { + console.error(err); + setError("Impossible de charger les données."); + } + } finally { + if (!signal.aborted) { + setLoading(false); + } + } + }; + + fetchData(); + + return () => controller.abort(); }, [tripId, navigate]); - const removeParticipant = (userId: number) => { + const removeParticipant = async (userId: number) => { if (!tripId) return; - setIsDeleting(true); - - fetch( - `${import.meta.env.VITE_API_URL}/api/invitation/${tripId}/${userId}`, - { - method: "DELETE", - }, - ) - .then(async (response) => { - if (response.status === 400) { - toast.error("Requête invalide"); - return; - } + try { + setIsDeleting(true); - if (response.status === 403) { - toast.error("Accès non autorisé"); - return; - } + const response = await fetch( + `${import.meta.env.VITE_API_URL}/api/invitation/${tripId}/${userId}`, + { method: "DELETE" }, + ); - if (response.status === 404) { - toast.error("Membre introuvable"); - return; - } + if (!response.ok) { + throw new Error(); + } - if (!response.ok) { - toast.error("Erreur serveur."); - return; - } + setAttendees((prev) => + prev.filter((participant) => participant.id !== userId), + ); - setAttendees((prev) => - prev.filter((participant) => participant.id !== userId), - ); - - toast.success("Membre retiré du voyage."); - }) - .catch(() => { - toast.error("Erreur serveur."); - }) - .finally(() => { - setIsDeleting(false); - setdeleteInvitation(null); - }); + toast.success("Membre retiré du voyage."); + } catch { + toast.error("Erreur lors de la suppression."); + } finally { + setIsDeleting(false); + setDeleteInvitation(null); + } }; return ( <> - {!loading && trip && } + {!loading && trip && } +
+
- {loading &&

Chargement des membres

} + {loading &&

Chargement des membres...

} + {error &&

{error}

} {!loading && !error && ( @@ -217,8 +181,9 @@ function Invitations() { title="Participants" invited={attendees} type="attendees" - delete={setdeleteInvitation} + delete={setDeleteInvitation} /> +

Retirer ce membre ?

+

Voulez-vous vraiment retirer{" "} {deleteInvitation.name} de ce voyage ? @@ -240,15 +206,16 @@ function Invitations() {

+
+ +
+ 📍 {city}, {country} +
+ +
+
+ 📅 {formatDate(startAt)} - {formatDate(endAt)} +
+ +
+ 👥 {participants ?? 0} participant + {participants && participants > 1 ? "s" : ""} +
+
+ -

- {city}, {country} -

-

- {formatDate(startAt)} - {formatDate(endAt)} -

-

{participants} participant(s)

-

{role}

- - ); } diff --git a/client/src/pages/TripInvitation.tsx b/client/src/pages/TripInvitation.tsx index 4953d77..91eb18a 100644 --- a/client/src/pages/TripInvitation.tsx +++ b/client/src/pages/TripInvitation.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useParams } from "react-router"; import { ToastContainer, toast } from "react-toastify"; -import "./styles/Invitation.css"; import "./styles/TripInvitation.css"; type InvitationForm = { @@ -30,6 +29,15 @@ function TripInvitation({ participants, onClose, }: TripInvitationProps) { + const { id } = useParams<{ id: string }>(); + + const [invitationForm, setInvitationForm] = useState({ + email: "", + message: "", + }); + + const [loading, setLoading] = useState(false); + const formatDate = (dateString: string) => { if (!dateString) return ""; const date = new Date(dateString); @@ -41,14 +49,6 @@ function TripInvitation({ year: "numeric", }).format(date); }; - const { id } = useParams<{ id: string }>(); - - const [invitationForm, setInvitationForm] = useState({ - email: "", - message: "", - }); - - const [loading, setLoading] = useState(false); const updateInvitationForm = ( e: React.ChangeEvent, @@ -101,7 +101,6 @@ function TripInvitation({ } await copyToClipboard(data.invitationLink); - setInvitationForm({ email: "", message: "" }); } catch { toast.error("Erreur lors de l'envoi de l'invitation"); @@ -111,38 +110,50 @@ function TripInvitation({ }; return ( - <> -
{}} - > - - -
+
{ + if (e.key === "Escape" && onClose) { + onClose(e as unknown as React.MouseEvent); + } + }} + aria-modal="true" + > + + +
+

- + Inviter un participant

-

Invitez une personne à rejoindre ce voyage par email

-
+ Invitez une personne à rejoindre ce voyage par email +
-
+
-
+

{title}

-

- {city}, {country} -

-

- {formatDate(startAt)} - {formatDate(endAt)} -

-

{participants} participant(s)

-
+ +
+ 📍 {city}, {country} +
+ +
+
+ 📅 {formatDate(startAt)} - {formatDate(endAt)} +
+ +
+ 👥 {participants ?? 0} participant + {participants && participants > 1 ? "s" : ""} +
+
+
-