Repository: Magenta-Mause/Medals-Frontend
Pull request #24: [MED-64] request athletes access
Author: login: joonjester
Created: 2025-03-10T19:41:58Z | Merged: 2025-04-07T09:05:50Z

Implemented the Frontend with a invite button and a validation page


Diff:
diff --git a/src/components/ProtectedRoute/ProtectedRoute.tsx b/src/components/ProtectedRoute/ProtectedRoute.tsx
index a34f6703..50d5da45 100644
--- a/src/components/ProtectedRoute/ProtectedRoute.tsx
+++ b/src/components/ProtectedRoute/ProtectedRoute.tsx
@@ -40,6 +40,7 @@ const rolePermissions = new Map<UserType | undefined, string[]>([
       "/performances",
       "/downloads",
       "/help",
+      "validateInvite",
     ],
   ],
 ]);
diff --git a/src/components/RoutingComponent/RoutingComponent.tsx b/src/components/RoutingComponent/RoutingComponent.tsx
index 7e813dcd..7a0dc67b 100644
--- a/src/components/RoutingComponent/RoutingComponent.tsx
+++ b/src/components/RoutingComponent/RoutingComponent.tsx
@@ -16,7 +16,9 @@ import PerformanceMetricsPage from "@pages/PerformanceMetrics/PerformanceMetrics
 import SetPasswordPage from "@pages/SetPassword/SetPasswordPage";
 import TrainerOverviewPage from "@pages/Trainers/TrainerOverviewPage";
 import UserRoleErrorPage from "@pages/UserRoleError/UserRoleErrorPage";
+import ValidateInvitePage from "@pages/AcceptTrainerAccessRequestPage/AcceptTrainerAccessRequestPage";
 import { useContext } from "react";
+
 import { Route, Routes } from "react-router";
 
 const RoutingComponent = () => {
@@ -27,6 +29,7 @@ const RoutingComponent = () => {
       <Route path="/login" element={<LoginPage />} />
       <Route path="/setPassword" element={<SetPasswordPage />} />
       <Route path="/resetPassword" element={<ResetPasswordPage />} />
+      <Route path="/approve-request" element={<ValidateInvitePage />} />
       <Route path="/" element={<PageLayout />}>
         <Route index element={<HomePage />} />
         <Route path="/imprint" element={<ImprintPage />} />
diff --git a/src/components/datagrids/AthleteDatagrid/AthleteDatagrid.tsx b/src/components/datagrids/AthleteDatagrid/AthleteDatagrid.tsx
index bb7de451..d11995fe 100644
--- a/src/components/datagrids/AthleteDatagrid/AthleteDatagrid.tsx
+++ b/src/components/datagrids/AthleteDatagrid/AthleteDatagrid.tsx
@@ -6,6 +6,7 @@ import { useNavigate } from "react-router";
 import { Column } from "../GenericResponsiveDatagrid/FullScreenTable";
 import GenericResponsiveDatagrid, {
   Action,
+  ToolbarAction,
 } from "../GenericResponsiveDatagrid/GenericResponsiveDatagrid";
 import { Filter } from "../GenericResponsiveDatagrid/GenericResponsiveDatagridFilterComponent";
 import { MobileTableRendering } from "../GenericResponsiveDatagrid/MobileTable";
@@ -16,6 +17,10 @@ import {
   convertMedalToNumber,
 } from "@utils/calculationUtil";
 import MedalIcon from "@components/MedalIcon/MedalIcon";
+import { useState } from "react";
+import AthleteRequestButton from "@components/modals/AthleteRequestModal/AthleteRequestModal";
+import { PersonSearch, PersonAdd } from "@mui/icons-material";
+import AthleteCreationForm from "@components/modals/AthleteCreationModal/AthleteCreationModal";
 
 interface AthleteDatagridProps {
   athletes: Athlete[];
@@ -29,6 +34,9 @@ const AthleteDatagrid = (props: AthleteDatagridProps) => {
   ) as PerformanceRecording[];
   const navigate = useNavigate();
   const { t } = useTranslation();
+  const [addAthleteRequestModalOpen, setAddAthleteRequestModalOpen] =
+    useState(false);
+  const [createAthletModalOpen, setCreateAthleteModalOpen] = useState(false);
 
   const columns: Column<Athlete>[] = [
     {
@@ -176,6 +184,35 @@ const AthleteDatagrid = (props: AthleteDatagridProps) => {
     },
   ];
 
+  const toolbarActions: ToolbarAction[] = [
+    {
+      label: t("components.athleteDatagrid.table.toolbar.createAthlete.label"),
+      content: t(
+        "components.athleteDatagrid.table.toolbar.createAthlete.content",
+      ),
+      icon: <PersonAdd />,
+      collapseToText: true,
+      color: "primary",
+      key: "invite-trainer",
+      variant: "solid",
+      operation: async () => {
+        setCreateAthleteModalOpen(true);
+      },
+    },
+    {
+      label: t("components.athleteDatagrid.table.toolbar.addAthlete.label"),
+      content: t("components.athleteDatagrid.table.toolbar.addAthlete.content"),
+      icon: <PersonSearch />,
+      collapseToText: true,
+      color: "primary",
+      key: "invite-trainer",
+      variant: "solid",
+      operation: async () => {
+        setAddAthleteRequestModalOpen(true);
+      },
+    },
+  ];
+
   const actions: Action<Athlete>[] = [
     {
       label: <>{t("components.athleteDatagrid.actions.edit")}</>,
@@ -269,6 +306,7 @@ const AthleteDatagrid = (props: AthleteDatagridProps) => {
         data={props.athletes}
         columns={columns}
         filters={filters}
+        toolbarActions={toolbarActions}
         actionMenu={actions}
         itemSelectionActions={actions}
         keyOf={(item) => item.id!}
@@ -276,6 +314,14 @@ const AthleteDatagrid = (props: AthleteDatagridProps) => {
         onItemClick={itemCallback}
         disablePaging={false}
       />
+      <AthleteRequestButton
+        isOpen={addAthleteRequestModalOpen}
+        setOpen={setAddAthleteRequestModalOpen}
+      />
+      <AthleteCreationForm
+        isOpen={createAthletModalOpen}
+        setOpen={setCreateAthleteModalOpen}
+      />
     </>
   );
 };
diff --git a/src/components/modals/AthleteCreationModal/AthleteCreationModal.tsx b/src/components/modals/AthleteCreationModal/AthleteCreationModal.tsx
index 046e881b..f75966f4 100644
--- a/src/components/modals/AthleteCreationModal/AthleteCreationModal.tsx
+++ b/src/components/modals/AthleteCreationModal/AthleteCreationModal.tsx
@@ -15,10 +15,14 @@ const emailRegex = // eslint-disable-next-line no-control-regex
 
 const isValidEmail = (email: string) => emailRegex.test(email);
 
-const AthleteCreationForm = () => {
+interface AthleteCreateModalProps {
+  isOpen: boolean;
+  setOpen: React.Dispatch<React.SetStateAction<boolean>>;
+}
+
+const AthleteCreationForm = (props: AthleteCreateModalProps) => {
   const { t } = useTranslation();
   const { createAthlete } = useApi();
-  const [isPopupOpen, setPopupOpen] = useState(false);
   const [athlete, setAthlete] = useState<Athlete>({
     first_name: "",
     last_name: "",
@@ -123,13 +127,10 @@ const AthleteCreationForm = () => {
 
   return (
     <>
-      <Button color="primary" onClick={() => setPopupOpen(true)}>
-        {t("pages.athleteOverviewPage.createButton")}
-      </Button>
       <GenericModal
         header={t("pages.athleteCreationPage.createHeader")}
-        open={isPopupOpen}
-        setOpen={setPopupOpen}
+        open={props.isOpen}
+        setOpen={props.setOpen}
         modalSX={{
           display: "flex",
           justifyContent: "center",
@@ -252,7 +253,7 @@ const AthleteCreationForm = () => {
           onClick={() => {
             {
               createAthlete(athlete);
-              setPopupOpen(false);
+              props.setOpen(false);
               setAthlete({
                 first_name: "",
                 last_name: "",
diff --git a/src/components/modals/AthleteRequestModal/AthleteRequestModal.tsx b/src/components/modals/AthleteRequestModal/AthleteRequestModal.tsx
new file mode 100644
index 00000000..6f7d5d8f
--- /dev/null
+++ b/src/components/modals/AthleteRequestModal/AthleteRequestModal.tsx
@@ -0,0 +1,229 @@
+import useApi from "@hooks/useApi";
+import {
+  Box,
+  Button,
+  List,
+  Input,
+  ListItem,
+  ListItemContent,
+  Typography,
+  Divider,
+} from "@mui/joy";
+import { useTranslation } from "react-i18next";
+import { useState, useEffect, useContext, useRef } from "react";
+import { CircularProgress } from "@mui/material";
+import { Athlete } from "@customTypes/backendTypes";
+import { AuthContext } from "@components/AuthenticationProvider/AuthenticationProvider";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import GenericModal from "@components/modals/GenericModal";
+import { enqueueSnackbar } from "notistack";
+import React from "react";
+
+interface AthleteRequestModalProps {
+  isOpen: boolean;
+  setOpen: React.Dispatch<React.SetStateAction<boolean>>;
+}
+
+const AthleteRequestButton = (props: AthleteRequestModalProps) => {
+  const { t } = useTranslation();
+  const { searchAthletes, requestAthlete } = useApi();
+  const [loading, setLoading] = useState(false);
+  const [icon, setShowScrollIcon] = useState(false);
+  const [searchAthlete, setSearchAthlete] = useState("");
+  const [filteredResults, setFilteredResults] = useState<Athlete[]>([]);
+  const { selectedUser } = useContext(AuthContext);
+  const [buttonState, setButtonState] = useState<{
+    [key: number]: { loading: boolean; send: boolean };
+  }>({});
+  const listRef = useRef<HTMLUListElement | null>(null);
+
+  const isButtonDisabled = (athleteId: number | undefined): boolean => {
+    if (athleteId === undefined) return true;
+
+    const athleteState = buttonState[athleteId];
+    return athleteState?.loading || athleteState?.send || false;
+  };
+
+  const handleInvite = async (
+    athleteId: number | undefined,
+    trainerId: number | undefined,
+  ) => {
+    if (athleteId === undefined || trainerId === undefined) {
+      return;
+    }
+    setButtonState((prevState) => ({
+      ...prevState,
+      [athleteId]: { loading: true, send: true },
+    }));
+
+    try {
+      await requestAthlete(athleteId, trainerId);
+      enqueueSnackbar(t("snackbar.requestAthleteAccess.success"), {
+        variant: "success",
+      });
+    } catch (error) {
+      console.error("Error requesting athlete", error);
+      enqueueSnackbar(t("snackbar.requestAthleteAccess.failed"), {
+        variant: "success",
+      });
+    }
+  };
+
+  const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setSearchAthlete(e.target.value);
+  };
+
+  useEffect(() => {
+    if (searchAthlete.trim() === "") {
+      setFilteredResults([]);
+      setLoading(false);
+      return;
+    }
+
+    const delayDebounceFn = setTimeout(async () => {
+      try {
+        const athletes = await searchAthletes(searchAthlete);
+        setFilteredResults(athletes);
+        if (athletes.length > 5) {
+          setShowScrollIcon(true);
+        }
+      } catch (error) {
+        console.error("Error fetching athletes", error);
+        setFilteredResults([]);
+      }
+      setLoading(false);
+    }, 500);
+
+    setLoading(true);
+    return () => clearTimeout(delayDebounceFn);
+  }, [searchAthletes, searchAthlete]);
+
+  const handleScroll = () => {
+    if (listRef.current) {
+      const { scrollTop, scrollHeight, clientHeight } = listRef.current;
+      if (scrollTop + clientHeight === scrollHeight) {
+        setShowScrollIcon(false);
+      } else {
+        setShowScrollIcon(true);
+      }
+    }
+  };
+
+  return (
+    <>
+      <GenericModal
+        header={t("components.requestAthleteModal.header")}
+        open={props.isOpen}
+        setOpen={props.setOpen}
+        modalSX={{
+          display: "flex",
+          justifyContent: "center",
+          alignItems: "center",
+          left: {
+            md: "var(--Sidebar-width)",
+            sm: "0",
+          },
+        }}
+      >
+        <Box
+          sx={(theme) => ({
+            width: { md: "35vw" },
+            borderRadius: "8px",
+            background: "rgba(251, 252, 254, 1)",
+            [theme.getColorSchemeSelector("dark")]: {
+              background: "rgba(11, 13, 14, 1)",
+            },
+          })}
+        >
+          <Box sx={{ position: "relative", width: "100%" }}>
+            <Input
+              sx={{
+                width: "100%",
+                marginBottom: "1vh",
+                height: { sx: "3vh", md: "5vh", xs: "5vh" },
+              }}
+              placeholder={t("components.requestAthleteModal.searchAthlete")}
+              size="lg"
+              value={searchAthlete}
+              onChange={handleSearchChange}
+            />
+            {loading && (
+              <CircularProgress
+                size={24}
+                sx={{
+                  position: "absolute",
+                  top: "50%",
+                  right: "5%",
+                  marginTop: "-12px",
+                  marginLeft: "-12px",
+                }}
+              />
+            )}
+          </Box>
+          <List
+            ref={listRef}
+            onScroll={handleScroll}
+            sx={{ maxHeight: 400, overflowY: "auto" }}
+          >
+            {filteredResults.length === 0 && (
+              <Typography sx={{ padding: 2, paddingBottom: 1 }}>
+                {t("components.requestAthleteModal.notFound")}
+              </Typography>
+            )}
+
+            {filteredResults.map((athlete, index) => (
+              <>
+                <ListItem
+                  key={index}
+                  sx={(theme) => ({
+                    padding: 1,
+                    width: "100%",
+                    borderRadius: 10,
+                    "&:hover": {
+                      background: "rgba(199, 199, 199, 0.6)",
+                    },
+                    [theme.getColorSchemeSelector("dark")]: {
+                      "&:hover": {
+                        background: "rgba(64, 64, 64, 0.6)",
+                      },
+                    },
+                  })}
+                >
+                  <ListItemContent>
+                    <Typography level="title-sm">{`${athlete.first_name} ${athlete.last_name}`}</Typography>
+                    <Typography level="body-sm" noWrap>
+                      {`${athlete.birthdate}`}
+                    </Typography>
+                  </ListItemContent>
+                  <Button
+                    onClick={() => {
+                      handleInvite(athlete.id, selectedUser?.id);
+                    }}
+                    sx={{
+                      cursor: "pointer",
+                    }}
+                    disabled={isButtonDisabled(athlete.id)}
+                  >
+                    {isButtonDisabled(athlete.id)
+                      ? t("components.requestAthleteModal.sendButton")
+                      : t(
+                          "components.requestAthleteModal.requestAthleteButton",
+                        )}
+                  </Button>
+                </ListItem>
+                <Divider sx={{ margin: 0.7 }} component="li" />
+              </>
+            ))}
+          </List>
+          {icon && (
+            <ExpandMoreIcon
+              sx={{ position: "absolute", bottom: 15, left: "48%" }}
+            />
+          )}
+        </Box>
+      </GenericModal>
+    </>
+  );
+};
+
+export default AthleteRequestButton;
diff --git a/src/hooks/useApi.tsx b/src/hooks/useApi.tsx
index b9012f87..4e069ca3 100644
--- a/src/hooks/useApi.tsx
+++ b/src/hooks/useApi.tsx
@@ -142,6 +142,66 @@ const useApi = () => {
     }
   };
 
+  const approveRequest = useCallback(
+    async (oneTimeCode: string, selectedUser: number) => {
+      try {
+        const response = await axiosInstance!.post(
+          `/athletes/approve-access?oneTimeCode=${oneTimeCode}`,
+          {},
+          {
+            headers: {
+              "X-Selected-User": selectedUser,
+            },
+          },
+        );
+
+        if (response.status !== 200) {
+          throw new Error(
+            `Error during accepting invite: ${response.statusText}`,
+          );
+        }
+      } catch (error) {
+        console.error(`Error while accepting invite`, error);
+        throw error;
+      }
+    },
+    [axiosInstance],
+  );
+
+  const requestAthlete = async (athleteId: number, trainerId: number) => {
+    try {
+      const response = await axiosInstance!.post(
+        "/trainers/request-athlete-access",
+        {
+          athleteId: athleteId,
+          trainerId: trainerId,
+        },
+      );
+      return response.status === 200;
+    } catch (error) {
+      console.error("Error requesting athlete:", error);
+      return false;
+    }
+  };
+
+  const searchAthletes = useCallback(
+    async (athlete: string) => {
+      try {
+        const request = await axiosInstance!.get(
+          `/trainers/search-athletes?athleteSearch=${athlete}`,
+        );
+        return request.data.data as Athlete[];
+      } catch (error) {
+        console.error(
+          `Error while fetching trainer with id: ${athlete}`,
+          error,
+        );
+        throw new Error("Error searching for athlete");
+      }
+    },
+    [axiosInstance],
+  );
+
   const loginUser = useCallback(
     async (email: string, password: string) => {
       const request = await axiosInstance!.post(
@@ -290,6 +350,9 @@ const useApi = () => {
     createPerformanceRecording,
     deletePerformanceRecording,
     getDisciplineMetrics,
+    approveRequest,
+    requestAthlete,
+    searchAthletes,
   };
 };
 
diff --git a/src/hooks/useAxiosInstance.tsx b/src/hooks/useAxiosInstance.tsx
index b483f26b..29b506c7 100644
--- a/src/hooks/useAxiosInstance.tsx
+++ b/src/hooks/useAxiosInstance.tsx
@@ -12,7 +12,8 @@ const useAxiosInstance = (baseUrl: string): AxiosInstance => {
     instance.interceptors.request.use(
       (config) => {
         config.headers.Authorization = authHeader;
-        config.headers["X-Selected-User"] = selectedUser?.id;
+        config.headers["X-Selected-User"] =
+          config.headers["X-Selected-User"] ?? selectedUser?.id;
         return config;
       },
       (error) => Promise.reject(error),
diff --git a/src/i18n/de-DE/translation.json b/src/i18n/de-DE/translation.json
index eeb0b801..abef5e54 100644
--- a/src/i18n/de-DE/translation.json
+++ b/src/i18n/de-DE/translation.json
@@ -82,6 +82,15 @@
     "performanceMetrics": {
       "athleteNotFound": "Athlet*in konnte nicht gefunden werden",
       "fetchAthleteFailed": "Fehler beim Laden der Athlet*in-Daten"
+    },
+    "acceptTrainerAccessRequest": {
+      "noLogin": "Melden Sie sich an und nehmen Sie die Einladung erneut an",
+      "failed": "Trainer Anfrage konnte nicht angenommen werden",
+      "success": "Trainer Anfrage erfolgreich angenommen"
+    },
+    "requestAthleteAccess": {
+      "failed": "Anfrage fehlgeschlagen",
+      "success": "Anfrage gesendet"
     }
   },
   "pages": {
@@ -93,8 +102,7 @@
       "createPerformanceRecordingButton": "Leistung aufnehmen"
     },
     "athleteOverviewPage": {
-      "header": "Athlet*innen",
-      "createButton": "Athlet*in erstellen"
+      "header": "Athlet*innen"
     },
     "athleteCreationPage": {
       "firstName": "Vorname",
@@ -174,6 +182,11 @@
         }
       }
     },
+    "validateRequestPage": {
+      "header": "Anfrage",
+      "subheader": "will Sie verwalten.\n Möchten Sie die Anfrage annehmen?",
+      "accept": "Annehmen"
+    },
     "profilePage": {
       "header": "Mein Profil",
       "birthdate": "Geburtstag",
@@ -278,6 +291,13 @@
         "ageAtRecording": "Berechnetes Alter bei Aufnahme: "
       }
     },
+    "requestAthleteModal": {
+      "header": "Athlet*innen anfragen",
+      "searchAthlete": "Name",
+      "notFound": "Kein Athleten gefunden",
+      "requestAthleteButton": "Senden",
+      "sendButton": "Gesendet"
+    },
     "athleteDetailHeader": {
       "columns": {
         "id": "ID",
@@ -361,6 +381,16 @@
           "search": "Suche",
           "birthYear": "Geburtsjahr",
           "gender": "Geschlecht"
+        },
+        "toolbar": {
+          "addAthlete": {
+            "label": "Anfragen",
+            "content": "Athlet*innen anfragen"
+          },
+          "createAthlete": {
+            "label": "Erstellen",
+            "content": "Athlet*in erstellen"
+          }
         }
       },
       "actions": {
diff --git a/src/i18n/en-US/translation.json b/src/i18n/en-US/translation.json
index 5acb8f35..a4c72e62 100644
--- a/src/i18n/en-US/translation.json
+++ b/src/i18n/en-US/translation.json
@@ -80,6 +80,15 @@
     "performanceMetrics": {
       "athleteNotFound": "Athlete not found",
       "fetchAthleteFailed": "Failed to fetch athlete data"
+    },
+    "acceptTrainerAccessRequest": {
+      "noLogin": "Login and accept the invitation again",
+      "failed": "Could not accept the request",
+      "success": "Request accepted"
+    },
+    "requestAthleteAccess": {
+      "failed": "Failed sending the request",
+      "success": "Request has been send"
     }
   },
   "pages": {
@@ -91,8 +100,7 @@
       "createPerformanceRecordingButton": "Record performance"
     },
     "athleteOverviewPage": {
-      "header": "Athletes",
-      "createButton": "Create athlete"
+      "header": "Athletes"
     },
     "athleteCreationPage": {
       "firstName": "First Name",
@@ -171,6 +179,11 @@
         }
       }
     },
+    "validateRequestPage": {
+      "header": "Request",
+      "subheader": "has requested access to you,\n would you like to accept the request?",
+      "accept": "Accept"
+    },
     "profilePage": {
       "header": "My Profile",
       "birthdate": "Birthdate",
@@ -265,6 +278,13 @@
         "/userRoleErrorPage": "User Role Error"
       }
     },
+    "requestAthleteModal": {
+      "header": "Request access to Athlete",
+      "searchAthlete": "Name",
+      "notFound": "No athletes found",
+      "requestAthleteButton": "Send",
+      "sendButton": "Sendet"
+    },
     "athleteDetailHeader": {
       "columns": {
         "id": "ID",
@@ -356,6 +376,16 @@
           "search": "Search",
           "birthYear": "Birth year",
           "gender": "Gender"
+        },
+        "toolbar": {
+          "addAthlete": {
+            "label": "Request",
+            "content": "Request athletes"
+          },
+          "createAthlete": {
+            "label": "Create",
+            "content": "Create athlete"
+          }
         }
       },
       "actions": {
diff --git a/src/pages/AcceptTrainerAccessRequestPage/AcceptTrainerAccessRequestPage.tsx b/src/pages/AcceptTrainerAccessRequestPage/AcceptTrainerAccessRequestPage.tsx
new file mode 100644
index 00000000..cfc93129
--- /dev/null
+++ b/src/pages/AcceptTrainerAccessRequestPage/AcceptTrainerAccessRequestPage.tsx
@@ -0,0 +1,155 @@
+import useApi from "@hooks/useApi";
+import SplitPageComponent from "@components/SplitPageComponent/SplitPageComponent";
+import { useContext, useEffect, useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { AuthContext } from "@components/AuthenticationProvider/AuthenticationProvider";
+import { Box, Stack, Button, Typography } from "@mui/joy";
+import { enqueueSnackbar } from "notistack";
+import { useNavigate, useSearchParams } from "react-router";
+import LoginForm from "@pages/Login/LoginForm";
+import { jwtDecode } from "jwt-decode";
+
+interface DecodeJWT {
+  trainerName: string;
+  athleteId: number;
+}
+
+const AcceptTrainerAccessRequest = () => {
+  const { t } = useTranslation();
+  const { approveRequest, loginUser } = useApi();
+  const [trainerName, setTrainerName] = useState<string>("");
+  const [athleteId, setAthleteId] = useState<number>();
+  const [searchParams] = useSearchParams();
+  const { refreshIdentityToken, authorized } = useContext(AuthContext);
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    if (authorized) {
+      const oneTimeCode = searchParams.get("oneTimeCode");
+      const uuidRegex = new RegExp(
+        "^[A-Za-z0-9_-]{10,}.[A-Za-z0-9_-]{10,}.[A-Za-z0-9_-]{10,}$",
+      );
+
+      if (!oneTimeCode || !uuidRegex.test(oneTimeCode)) {
+        return;
+      }
+
+      const decoded = jwtDecode<DecodeJWT>(oneTimeCode);
+      setTrainerName(decoded.trainerName);
+      setAthleteId(decoded.athleteId);
+    }
+  }, [searchParams, authorized]);
+
+  const accept = async (oneTimeCode: string, athleteId: number) => {
+    try {
+      await approveRequest(oneTimeCode, athleteId);
+      enqueueSnackbar(t("snackbar.acceptTrainerAccessRequest.success"), {
+        variant: "success",
+      });
+    } catch {
+      enqueueSnackbar(t("snackbar.acceptTrainerAccessRequest.failed"), {
+        variant: "error",
+      });
+    }
+  };
+
+  const loginCallback = async (loginData: {
+    email: string;
+    password: string;
+    privacyPolicy: boolean;
+  }) => {
+    try {
+      const res = await loginUser(loginData.email, loginData.password);
+
+      if (res) {
+        await refreshIdentityToken();
+      } else {
+        enqueueSnackbar("Login failed", {
+          variant: "error",
+        });
+      }
+    } catch (error) {
+      console.log("Error during login", error);
+      enqueueSnackbar(t("snackbar.login.loginFailed"), { variant: "error" });
+    }
+  };
+
+  const { isPending, mutate: login } = useMutation({
+    mutationFn: loginCallback,
+  });
+
+  return (
+    <SplitPageComponent>
+      <Box
+        component="main"
+        sx={(theme) => ({
+          my: "auto",
+          py: 2,
+          pb: 5,
+          display: "flex",
+          flexDirection: "column",
+          gap: 2,
+          width: 400,
+          maxWidth: "100%",
+          mx: "auto",
+          borderRadius: "sm",
+          "& form": {
+            display: "flex",
+            flexDirection: "column",
+            gap: 2,
+          },
+          background: "rgba(236 236 231 / 0.9)",
+          [theme.getColorSchemeSelector("dark")]: {
+            background: "rgba(19 19 24 / 0.8)",
+          },
+          p: 5,
+        })}
+      >
+        {!authorized ? (
+          <>
+            <Stack sx={{ gap: 4, mb: 2 }}>
+              <Stack sx={{ gap: 1 }}>
+                <Typography component="h1" level="h3">
+                  {t("pages.loginPage.signIn.header")}
+                </Typography>
+                <Typography level="body-sm" sx={{ whiteSpace: "pre-line" }}>
+                  {t("pages.loginPage.signIn.subheader")}
+                </Typography>
+              </Stack>
+            </Stack>
+            <LoginForm loginCallback={login} isPending={isPending} />
+          </>
+        ) : (
+          <Stack>
+            <Stack sx={{ gap: 4, mb: 2 }}>
+              <Stack sx={{ gap: 1 }}>
+                <Typography component="h1" level="h3">
+                  {t("pages.validateRequestPage.header")}
+                </Typography>
+                <Typography level="body-sm" sx={{ whiteSpace: "pre-line" }}>
+                  <span style={{ fontWeight: "bold" }}>{trainerName}</span>{" "}
+                  {t("pages.validateRequestPage.subheader")}
+                </Typography>
+              </Stack>
+            </Stack>
+            <Button
+              onClick={() => {
+                const oneTimeCode = searchParams.get("oneTimeCode");
+                if (oneTimeCode && athleteId) {
+                  accept(oneTimeCode, athleteId);
+                  navigate("/");
+                }
+              }}
+              color="success"
+            >
+              {t("pages.validateRequestPage.accept")}
+            </Button>
+          </Stack>
+        )}
+      </Box>
+    </SplitPageComponent>
+  );
+};
+
+export default AcceptTrainerAccessRequest;
diff --git a/src/pages/Athletes/AthleteOverviewPage.tsx b/src/pages/Athletes/AthleteOverviewPage.tsx
index afb2070e..7624584a 100644
--- a/src/pages/Athletes/AthleteOverviewPage.tsx
+++ b/src/pages/Athletes/AthleteOverviewPage.tsx
@@ -2,7 +2,6 @@ import AthleteDatagrid from "@components/datagrids/AthleteDatagrid/AthleteDatagr
 import { Box, Typography } from "@mui/joy";
 import { useTypedSelector } from "@stores/rootReducer";
 import { useTranslation } from "react-i18next";
-import AthleteCreationForm from "@components/modals/AthleteCreationModal/AthleteCreationModal";
 
 const AthleteOverviewPage = () => {
   const athletes = useTypedSelector((state) => state.athletes.data);
@@ -25,7 +24,6 @@ const AthleteOverviewPage = () => {
         <Typography level="h2" component="h1">
           {t("pages.athleteOverviewPage.header")}
         </Typography>
-        <AthleteCreationForm />
       </Box>
       <AthleteDatagrid
         athletes={athletes}


Reviews:
user: login: Janne6565
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-11T16:03:28Z
user: login: joonjester
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:38:01Z
user: login: Janne6565
body: gibt noch kleine fehler aber lass die einfach im nachhinein fixen, ist glaube ich entspannter
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-31T09:49:20Z
user: login: py-bay
body: ![image](https://github.com/user-attachments/assets/20785ce1-6df4-4f04-9a46-3c4d701d8ea7)

Bei mir ist der Button nicht klickbar. Kann sein, dass das aber ein komischer edge case bei mir ist.

Wenn das bei anderen funktioniert würde ich approven und wir kümmern uns ggf. später darum.

Auch sollte glaub ich das standartverhalten sein, dass wenn der trainer einen athleten erstellt automatisch die Anfrage an die athletes raus geht, das sie verwaltet werden sollen.
Aber das ist auf jeden Fall auch eine extra story.

state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:57:54Z
user: login: py-bay
body: visuell gefällt es mir aber schon
good job :)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T08:58:26Z
user: login: py-bay
body: 80% done
minor fixes later
my error was caused by old branch and same browser

good job
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-01T11:19:40Z
user: login: py-bay
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-01T12:45:44Z
user: login: EcoFreshKase
body: good job :) 🥇 
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-05T19:29:47Z
user: login: EcoFreshKase
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-07T08:26:09Z
user: login: py-bay
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-07T09:01:14Z

Review comments:
path: src/components/AthleteInvite/AthleteInvite.tsx
user: login: Janne6565
body: Lets use a more descriptive name for this component, is it a button or dropdown or whatever?
created_at: 2025-03-11T15:55:42Z
author_association: MEMBER
diff_hunk: @@ -38,6 +38,7 @@ const rolePermissions = new Map<UserType | undefined, string[]>([
       "/performances",
       "/downloads",
       "/help",
+      "validateInvite",
path: src/components/ProtectedRoute/ProtectedRoute.tsx
user: login: Janne6565
body: i dont think we need this here
created_at: 2025-03-11T15:57:27Z
author_association: MEMBER
diff_hunk: @@ -106,6 +106,36 @@ const useApi = () => {
     }
   };
 
+  const acceptInvite = async (oneTimeCode: string) => {
+    const response = await axiosInstance!.post(
+      `/athletes/validateInvite?oneTimeCode=${oneTimeCode}`,
path: src/hooks/useApi.tsx
user: login: Janne6565
body: i think you need to change the path here if you change it in the backend
created_at: 2025-03-11T15:58:07Z
author_association: MEMBER
diff_hunk: @@ -106,6 +106,36 @@ const useApi = () => {
     }
   };
 
+  const acceptInvite = async (oneTimeCode: string) => {
+    const response = await axiosInstance!.post(
path: src/hooks/useApi.tsx
user: login: Janne6565
body: lets wrap this in a try catch block
created_at: 2025-03-11T15:58:34Z
author_association: MEMBER
diff_hunk: @@ -106,6 +106,36 @@ const useApi = () => {
     }
   };
 
+  const acceptInvite = async (oneTimeCode: string) => {
+    const response = await axiosInstance!.post(
+      `/athletes/validateInvite?oneTimeCode=${oneTimeCode}`,
+    );
+
+    if (response.status !== 200) {
+      throw new Error(`Error during accepting invite: ${response.statusText}`);
+    }
+  };
+
+  const inviteAthlete = async (athleteId: number, trainerId: number) => {
+    const request = await axiosInstance!.post(`/trainers/invite-athlete`, {
+      athleteId: athleteId,
+      trainerId: trainerId,
+    });
+    return request.status == 200;
+  };
+
+  const getSimilarAthlete = async (athlete: string) => {
path: src/hooks/useApi.tsx
user: login: Janne6565
body: lets rename this to "searchAthletes" as this returns multiple athletes 
created_at: 2025-03-11T15:59:42Z
author_association: MEMBER
diff_hunk: @@ -106,6 +106,36 @@ const useApi = () => {
     }
   };
 
+  const acceptInvite = async (oneTimeCode: string) => {
+    const response = await axiosInstance!.post(
+      `/athletes/validateInvite?oneTimeCode=${oneTimeCode}`,
+    );
+
+    if (response.status !== 200) {
+      throw new Error(`Error during accepting invite: ${response.statusText}`);
+    }
+  };
+
+  const inviteAthlete = async (athleteId: number, trainerId: number) => {
+    const request = await axiosInstance!.post(`/trainers/invite-athlete`, {
+      athleteId: athleteId,
+      trainerId: trainerId,
+    });
+    return request.status == 200;
+  };
+
+  const getSimilarAthlete = async (athlete: string) => {
+    try {
+      const request = await axiosInstance!.get(
+        `/trainers/search-athletes?athleteSearch=${athlete}`,
+      );
+      return request.data.data as Athlete[];
+    } catch (error) {
+      console.error(`Error while fetching trainer with id: ${athlete}`, error);
+      return [];
path: src/hooks/useApi.tsx
user: login: Janne6565
body: please throw an error here and not only return a []
created_at: 2025-03-11T15:59:59Z
author_association: MEMBER
diff_hunk: @@ -30,6 +30,10 @@
     "inviteTrainer": {
       "failed": "Der Trainer konnte nicht eingeladen werden",
       "success": "Der Trainer wurde eingeladen"
+    },
+    "validateAthleteInvite": {
+      "failed": "Die Zuweisung konnte nicht erfolgen",
path: src/i18n/de-DE/translation.json
user: login: Janne6565
body: "Trainer Anfrage konnte nicht angenommen werden"
created_at: 2025-03-11T16:00:54Z
author_association: MEMBER
diff_hunk: @@ -30,6 +30,10 @@
     "inviteTrainer": {
       "failed": "Der Trainer konnte nicht eingeladen werden",
       "success": "Der Trainer wurde eingeladen"
+    },
+    "validateAthleteInvite": {
+      "failed": "Die Zuweisung konnte nicht erfolgen",
+      "success": "Du wurdest zugewiesen"
path: src/i18n/de-DE/translation.json
user: login: Janne6565
body: "Trainer Anfrage erfolgreich angenommen"
created_at: 2025-03-11T16:01:10Z
author_association: MEMBER
diff_hunk: @@ -30,6 +30,10 @@
     "inviteTrainer": {
       "failed": "Der Trainer konnte nicht eingeladen werden",
       "success": "Der Trainer wurde eingeladen"
+    },
+    "validateAthleteInvite": {
path: src/i18n/de-DE/translation.json
user: login: Janne6565
body: lets not use "validateAthleteInvite" everywhere, i think this doesnt quite describe what is happening, can we use something like "acceptTrainerAccessRequest" or something in that description?
created_at: 2025-03-11T16:01:49Z
author_association: MEMBER
diff_hunk: @@ -114,6 +119,10 @@
         }
       }
     },
+    "validateInvitePage": {
path: src/i18n/de-DE/translation.json
user: login: Janne6565
body: what is the difference between this and "validateAthleteInvite"
created_at: 2025-03-11T16:02:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,90 @@
+import { StrictMode, useEffect, useState } from "react";
+import SplitPageComponent from "@components/SplitPageComponent/SplitPageComponent";
+import { Box, Stack, Button } from "@mui/joy";
+import { useTranslation } from "react-i18next";
+import { useNavigate, useSearchParams } from "react-router";
+import useApi from "@hooks/useApi";
+import { enqueueSnackbar } from "notistack";
+
+const ValidateInvitePage = () => {
path: src/pages/ValidateInvitePage/ValidateInvitePage.tsx
user: login: Janne6565
body: lets also rename this here to "AcceptTrainerAccessRequest"
created_at: 2025-03-11T16:02:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,90 @@
+import { StrictMode, useEffect, useState } from "react";
+import SplitPageComponent from "@components/SplitPageComponent/SplitPageComponent";
+import { Box, Stack, Button } from "@mui/joy";
+import { useTranslation } from "react-i18next";
+import { useNavigate, useSearchParams } from "react-router";
+import useApi from "@hooks/useApi";
+import { enqueueSnackbar } from "notistack";
+
+const ValidateInvitePage = () => {
+  const { t } = useTranslation();
+  const { acceptInvite } = useApi();
+  const [isValid, setValid] = useState(false);
+  const [searchParams] = useSearchParams();
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    const oneTimeCode = searchParams.get("oneTimeCode");
+    const uuidRegex = new RegExp(
+      "^[A-Za-z0-9_-]{10,}.[A-Za-z0-9_-]{10,}.[A-Za-z0-9_-]{10,}$",
+    );
+    if (!oneTimeCode || !uuidRegex.test(oneTimeCode)) {
+      setValid(false);
+      return;
+    }
+
+    const debounce = setTimeout(() => {
+      try {
+        setValid(true);
+        acceptInvite(oneTimeCode);
+        enqueueSnackbar(t("snackbar.validateAthleteInvite.success"), {
+          variant: "success",
+        });
+      } catch {
+        enqueueSnackbar(t("snackbar.validateAthleteInvite.failed"), {
+          variant: "error",
+        });
+      }
+    }, 400);
+
+    return () => clearTimeout(debounce);
+  }, [searchParams, t]);
+
+  return (
+    <SplitPageComponent>
+      <Box
+        component="main"
+        sx={() => ({
+          my: "auto",
+          py: 2,
+          pb: 5,
+          display: "flex",
+          flexDirection: "column",
+          gap: 2,
+          width: 450,
+          maxWidth: "100%",
+          mx: "auto",
+          borderRadius: "sm",
+        })}
+      >
+        <StrictMode>
path: src/pages/ValidateInvitePage/ValidateInvitePage.tsx
user: login: Janne6565
body: why do you want this here?
created_at: 2025-03-11T16:03:10Z
author_association: MEMBER
diff_hunk: @@ -114,6 +119,10 @@
         }
       }
     },
+    "validateInvitePage": {
path: src/i18n/de-DE/translation.json
user: login: joonjester
body: this is the button when verifing
created_at: 2025-03-12T20:38:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -171,6 +179,11 @@
         }
       }
     },
+    "validateRequestPage": {
+      "header": "Request",
+      "subheader": "has requested access to you,\n would you like to accept the request?",
+      "accept": "Accpet"
path: src/i18n/en-US/translation.json
user: login: EcoFreshKase
body: Accept
created_at: 2025-04-05T19:04:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,229 @@
+import useApi from "@hooks/useApi";
+import {
+  Box,
+  Button,
+  List,
+  Input,
+  ListItem,
+  ListItemContent,
+  Typography,
+  Divider,
+} from "@mui/joy";
+import { useTranslation } from "react-i18next";
+import { useState, useEffect, useContext, useRef } from "react";
+import { CircularProgress } from "@mui/material";
+import { Athlete } from "@customTypes/backendTypes";
+import { AuthContext } from "@components/AuthenticationProvider/AuthenticationProvider";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import GenericModal from "@components/modals/GenericModal";
+import { enqueueSnackbar } from "notistack";
+import React from "react";
+
+interface AthleteRequestModalProps {
+  isOpen: boolean;
+  setOpen: React.Dispatch<React.SetStateAction<boolean>>;
+}
+
+const AthleteRequestButton = (props: AthleteRequestModalProps) => {
+  const { t } = useTranslation();
+  const { searchAthletes, requestAthlete } = useApi();
+  const [loading, setLoading] = useState(false);
+  const [icon, setShowScrollIcon] = useState(false);
+  const [searchAthlete, setSearchAthlete] = useState("");
+  const [filteredResults, setFilteredResults] = useState<Athlete[]>([]);
+  const { selectedUser } = useContext(AuthContext);
+  const [buttonState, setButtonState] = useState<{
+    [key: number]: { loading: boolean; send: boolean };
+  }>({});
+  const listRef = useRef<HTMLUListElement | null>(null);
+
+  const isButtonDisabled = (athleteId: number | undefined): boolean => {
+    if (athleteId === undefined) return true;
+
+    const athleteState = buttonState[athleteId];
+    return athleteState?.loading || athleteState?.send || false;
+  };
+
+  const handleInvite = async (
+    athleteId: number | undefined,
+    trainerId: number | undefined,
+  ) => {
+    if (athleteId === undefined || trainerId === undefined) {
+      return;
+    }
+    setButtonState((prevState) => ({
+      ...prevState,
+      [athleteId]: { loading: true, send: true },
+    }));
+
+    try {
+      await requestAthlete(athleteId, trainerId);
+      enqueueSnackbar(t("snackbar.requestAthleteAccess.success"), {
+        variant: "success",
+      });
+    } catch (error) {
+      console.error("Error inviting athlete", error);
+      enqueueSnackbar(t("snackbar.requestAthleteAccess.success"), {
path: src/components/modals/AthleteRequestModal/AthleteRequestModal.tsx
user: login: EcoFreshKase
body: I assume it is a errot that the same text is shown on success and error, right?
created_at: 2025-04-05T19:20:56Z
author_association: MEMBER

PR comments:
user: login: Janne6565
created_at: 2025-03-13T13:47:56Z
body: This feels weird to use, recommendations how it would feel better is: 
- somehow display if the search is currently running, or some indicator what is happening, 
- the list doesnt feel seperated from the list of athletes
- i dont think there currently is a limit on how many athletes are displayed or any kind of paging, this will lead to weird behaivior as soon as we have more then 10 athletes being displayed, also in the backend please dont return the entire athleteDto, as this contains all informations about the athlete, but its supposed to be a "lower specced" version of the athlete, so maybe lets just include the first name last name and birthdate, stuff like email etc. shouldnt be inside there
- currently it doesnt stop triggering the search request and just fires it every second, lets only do it on input change and then wait for like 200ms so it doesnt fire useless requests when typing, maybe have a look at useQuery and this chatgpt chat: https://chatgpt.com/share/67d2e1c4-dadc-800e-9906-3c5f44c8f7aa
- the popup doesnt feel nicely formatted, lets use the GenericModalComponent and add a nice header or something
- in darkmode the window still has a white background: 
![image](https://github.com/user-attachments/assets/f6ed35db-26dc-44c6-8ae4-4b7b352de679)
![image](https://github.com/user-attachments/assets/430c7458-2e78-40f2-8cb0-b023471b8497)

author_association: MEMBER
user: login: joonjester
created_at: 2025-03-20T18:59:56Z
body: Not completely fixed. There are two bugs that need to be fixed
author_association: CONTRIBUTOR
user: login: Janne6565
created_at: 2025-03-24T12:26:37Z
body: ![image](https://github.com/user-attachments/assets/aea81352-926b-42df-90f0-141e0748d65e)
The buttons are looking pretty weird, but this is fine, as we fix this in my PR by using the toolbar actions from fietes PR there. But i dont like the label "Athlet*in Zuweisen" im not sure what to do about it but we need something different. The loading feels way nicer now but the loading icon is in the middle of the input, please move it to the end. Also i think we should remove the searching by the email, this allows email enumeration stuff like this and makes stuff not realy intuitive:
![image](https://github.com/user-attachments/assets/1346e30f-2491-4050-8cdd-9ab901592598)
Also we have an issue with i18n:
![image](https://github.com/user-attachments/assets/36dc3614-ac5a-4c93-8186-9275adafb9ff)
And also i would prefer not automatically closing the window as i can imagine the trainer to be assigning more then one 
Also what is the behaivior when requesting access to an athlete more then once? I would prefer if we keep track in the backend of which athletes a trainer already requested access to. Maybe you can change the type of the table to something like "athleteId, trainerId, AssignmentStatus (Requested|Denied|Granted) not sure, maybe we could leave this out for now.
Also when a trainer does a GET "/api/v1/athletes" request it should only return the athletes which the trainer has access to, and also you should implement security checks so that not every trainer can see all athlete details, but only from those athletes whom which he has access to.
author_association: MEMBER
user: login: joonjester
created_at: 2025-03-25T09:11:40Z
body: >would prefer if we keep track in the backend of which athletes a trainer already requested access to. Maybe you can change the type of the table to something like "athleteId, trainerId, AssignmentStatus (Requested|Denied|Granted) not sure, maybe we could leave this out for now.


I would put this in the Story where the trainer has the ability to unassign an athlete. That has been assigned to that trainer. Since we probably need to change the Status anyway. Depends on if the athlete has multiplier trainers
author_association: CONTRIBUTOR
user: login: Janne6565
created_at: 2025-03-26T13:45:53Z
body: > > would prefer if we keep track in the backend of which athletes a trainer already requested access to. Maybe you can change the type of the table to something like "athleteId, trainerId, AssignmentStatus (Requested|Denied|Granted) not sure, maybe we could leave this out for now.
> 
> I would put this in the Story where the trainer has the ability to unassign an athlete. That has been assigned to that trainer. Since we probably need to change the Status anyway. Depends on if the athlete has multiplier trainers

i would prefer not to link it to that story as there is no real connection there. My problem with not doing it in this story is, that we would then in a different story change the quite a lot of implementation which was done in this story. But maybe we can also for now just keep it in the frontend and if you refresh the page then its gone. I guess that would be alright for now
author_association: MEMBER
user: login: Janne6565
created_at: 2025-03-26T13:49:39Z
body: ![image](https://github.com/user-attachments/assets/ca80964c-a2b8-465d-bfd2-5fdf1cf69840)
the contrast of the text is not good, please use mui joy <Typography> 
author_association: MEMBER
user: login: Janne6565
created_at: 2025-03-26T13:50:12Z
body: After i submit a request the search resets, this feels very non intuitive, please just leave the search bar and change the button to "send" or something
author_association: MEMBER
user: login: Janne6565
created_at: 2025-03-26T13:53:29Z
body: 
![image](https://github.com/user-attachments/assets/031df35e-fd18-444e-9e12-beca592b06cb)
I dont like the accept page, it should not automatically accept the invite but show some infos like "Hey, Trainer XY has requested Access to you, would you like to accept the request?" and then theres a button which says "Accept" or something and then your good to go. To get the trainer name maybe you can encrypt it into the jwt token body, this shouldnt be a lot of work in the backend
author_association: MEMBER
user: login: Janne6565
created_at: 2025-03-26T13:55:01Z
body: The invite page should only accept the invite if the user who is logged in is also the user to which access is requested, otherwise it should say something like "please login bla bla bla" idk
author_association: MEMBER
user: login: joonjester
created_at: 2025-03-26T20:26:56Z
body: i checks if the user is logged in or not. If not the login propt is shown 
author_association: CONTRIBUTOR
user: login: Janne6565
created_at: 2025-04-01T09:29:22Z
body: Not sure what your last push was, but from the commit name I don't think it does what we want it to do. What exactly is happening there? Do we just ignore the case if the jwt token is invalid or expired or wrong user?
author_association: MEMBER
user: login: joonjester
created_at: 2025-04-01T09:33:19Z
body: It was not able to get the trainersName from the Jwt. I am unsure if that is because of the jwt being invalid 

author_association: CONTRIBUTOR
user: login: Janne6565
created_at: 2025-04-01T09:36:18Z
body: > It was not able to get the trainersName from the Jwt. I am unsure if that is because of the jwt being invalid

but why would this happen? @py-bay do you have the latest version of joons backend branch? 

author_association: MEMBER