From eccdd07aafa4670efa220729082d3dda1f89fe73 Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Sat, 24 Aug 2024 22:29:53 +0200 Subject: [PATCH 01/13] feat: implement user auth --- frontend/src/api/backendApi.ts | 9 ++++ frontend/src/routes/_protected.tsx | 17 +++++++ frontend/src/routes/_protected/info.tsx | 38 ++++++++++++++ .../src/routes/auth/callback/keycloak.tsx | 51 +++++++++++++++++++ frontend/src/store/auth/authStore.ts | 31 +++++++++++ frontend/src/vite-env.d.ts | 19 ++++++- 6 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/_protected.tsx create mode 100644 frontend/src/routes/_protected/info.tsx create mode 100644 frontend/src/routes/auth/callback/keycloak.tsx create mode 100644 frontend/src/store/auth/authStore.ts diff --git a/frontend/src/api/backendApi.ts b/frontend/src/api/backendApi.ts index f83f5d48..fc970be6 100644 --- a/frontend/src/api/backendApi.ts +++ b/frontend/src/api/backendApi.ts @@ -1,17 +1,26 @@ import { Configuration, ConfigurationParameters, + HTTPHeaders, InfoApi, + LoginApi, TreesApi, } from "@green-ecolution/backend-client"; +const headers: HTTPHeaders = { + "Content-Type": "application/json", + "Accept": "application/json", +}; + const configParams: ConfigurationParameters = { basePath: import.meta.env.VITE_BACKEND_BASEURL ?? "/api-local", + headers }; const config = new Configuration(configParams); export const treeApi = new TreesApi(config); export const infoApi = new InfoApi(config); +export const loginApi = new LoginApi(config); export * from "@green-ecolution/backend-client"; diff --git a/frontend/src/routes/_protected.tsx b/frontend/src/routes/_protected.tsx new file mode 100644 index 00000000..314251ac --- /dev/null +++ b/frontend/src/routes/_protected.tsx @@ -0,0 +1,17 @@ +import { loginApi } from '@/api/backendApi' +import useAuthStore from '@/store/auth/authStore' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_protected')({ + beforeLoad: async () => { + const isAuthenticated = useAuthStore.getState().isAuthenticated + const currentPath = window.location.pathname + if (!isAuthenticated) { + const loginUrl = await loginApi.v1LoginGet({ + redirectUrl: `${window.location.origin}/auth/callback/keycloak?redirect=${currentPath}` + }).then((res) => res.loginUrl) + + window.location.href = loginUrl + } + } +}) diff --git a/frontend/src/routes/_protected/info.tsx b/frontend/src/routes/_protected/info.tsx new file mode 100644 index 00000000..505e4ad7 --- /dev/null +++ b/frontend/src/routes/_protected/info.tsx @@ -0,0 +1,38 @@ +import useAuthStore from "@/store/auth/authStore"; +import { infoApi } from "@/api/backendApi"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_protected/info")({ + component: Info, +}); + +function Info() { + const {apiHeader: authorization} = useAuthStore(state => ({apiHeader: state.apiHeader})) + + const { data, isFetching } = useQuery({ + queryKey: ["info"], + queryFn: () => infoApi.getAppInfo({ + authorization + }), + }); + + const { isAuthenticated, token } = useAuthStore((state) => ({ + isAuthenticated: state.isAuthenticated, + token: state.token, + })); + + return ( +
+

App Info

+

Authenticated: {isAuthenticated ? "Yes" : "No"}

+

Token: {token ? "Yes" : "No"}

+
{JSON.stringify(token, null, 2)}
+ {isFetching ? ( +

Loading...

+ ) : ( +
{JSON.stringify(data, null, 2)}
+ )} +
+ ); +} diff --git a/frontend/src/routes/auth/callback/keycloak.tsx b/frontend/src/routes/auth/callback/keycloak.tsx new file mode 100644 index 00000000..2ddea2a9 --- /dev/null +++ b/frontend/src/routes/auth/callback/keycloak.tsx @@ -0,0 +1,51 @@ +import { loginApi } from "@/api/backendApi"; +import useAuthStore from "@/store/auth/authStore"; +import { + createFileRoute, + redirect as routerRedirect, +} from "@tanstack/react-router"; +import { z } from "zod"; + +const authSearchParamsSchema = z.object({ + session_state: z.string(), + iss: z.string(), + code: z.string(), + redirect: z.string(), +}); + +export const Route = createFileRoute("/auth/callback/keycloak")({ + validateSearch: authSearchParamsSchema, + loaderDeps: ({ search: { code } }) => ({ code }), + beforeLoad: async ({ search: { code, redirect } }) => { + const token = await loginApi + .v1TokenPost({ + redirectUrl: `${window.location.origin}/auth/callback/keycloak?redirect=${redirect}`, + body: { + code, + }, + }) + .catch((err) => { + console.error(err); + throw new Error(err.message); + }); + + if (!token) { + console.error("Error while fetching token"); + throw new Error("Error while fetching token"); + } + + console.log("token", token); + + useAuthStore.setState((state) => ({ + ...state, + isAuthenticated: true, + token: token, + apiHeader: `${token.tokenType} ${token.accessToken}`, + })); + + throw routerRedirect({ + to: redirect, + replace: true, + }); + }, +}); diff --git a/frontend/src/store/auth/authStore.ts b/frontend/src/store/auth/authStore.ts new file mode 100644 index 00000000..bfc1721b --- /dev/null +++ b/frontend/src/store/auth/authStore.ts @@ -0,0 +1,31 @@ +import { ClientToken } from "@green-ecolution/backend-client"; +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; + +type State = { + isAuthenticated: boolean; + token: ClientToken | null; + apiHeader: string; +}; + +type Actions = { + setIsAuthenticated: (auth: boolean) => void; + setAccessToken: (token: string) => void; +}; + +type Store = State & Actions; + +const useAuthStore = create()( + devtools( + immer((set) => ({ + isAuthenticated: false, + token: null, + setIsAuthenticated: (auth) => set((state) => ({ ...state, isAuthenticated: auth })), + setAccessToken: (token) => set((state) => ({ ...state, accessToken: token })), + apiHeader: "", + })) + ) +); + +export default useAuthStore; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe2..a08a60f8 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,18 @@ -/// +/// Date: Sat, 24 Aug 2024 22:37:30 +0200 Subject: [PATCH 02/13] chore: add missing configs in other api calls --- frontend/src/context/TreeDataContext.tsx | 3 + .../src/routes/dashboard/tree/$treeId.tsx | 191 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 frontend/src/routes/dashboard/tree/$treeId.tsx diff --git a/frontend/src/context/TreeDataContext.tsx b/frontend/src/context/TreeDataContext.tsx index a4ec8e10..05a8d3b5 100644 --- a/frontend/src/context/TreeDataContext.tsx +++ b/frontend/src/context/TreeDataContext.tsx @@ -1,4 +1,5 @@ import { treeApi } from "@/api/backendApi"; +import useAuthStore from "@/store/auth/authStore"; import { Tree } from "@green-ecolution/backend-client"; import { useQuery } from "@tanstack/react-query"; import { createContext, useContext, useEffect } from "react"; @@ -15,11 +16,13 @@ export interface TreeDataContextProviderProps extends React.PropsWithChildren {} export const TreeDataContextProvider = ({ children, }: TreeDataContextProviderProps) => { + const {apiHeader} = useAuthStore(state => ({apiHeader: state.apiHeader})); const { data, isError, error } = useQuery({ queryKey: ["trees"], queryFn: () => treeApi.getAllTrees({ sensorData: true, + authorization: apiHeader, }), }); diff --git a/frontend/src/routes/dashboard/tree/$treeId.tsx b/frontend/src/routes/dashboard/tree/$treeId.tsx new file mode 100644 index 00000000..4d3a0570 --- /dev/null +++ b/frontend/src/routes/dashboard/tree/$treeId.tsx @@ -0,0 +1,191 @@ +import { treeApi } from "@/api/backendApi"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { useTree } from "@/context/TreeDataContext"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { + Bell, + Blocks, + Map, + MoreVertical, + Pencil, + Settings, +} from "lucide-react"; +import React from "react"; +import { useEffect, useMemo } from "react"; +import { toast } from "sonner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import TreeOverviewDashboard from "@/components/dashboard/tree/overview"; +import TreeSensorDashboard from "@/components/dashboard/tree/sensorView"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { format } from "date-fns"; +import useAuthStore from "@/store/auth/authStore"; + +export const Route = createFileRoute("/dashboard/tree/$treeId")({ + component: TreeDashboard, +}); + +function TreeDashboard() { + const { treeId } = Route.useParams(); + const tree = useTree(treeId); + const {apiHeader} = useAuthStore(state => ({apiHeader: state.apiHeader})); + + const { data, isError, error, isLoading, dataUpdatedAt } = useQuery({ + queryKey: ["tree_prediction", treeId], + refetchInterval: 10000, + queryFn: () => + treeApi.getTreePredictionById({ treeID: treeId, sensorData: true, authorization: apiHeader}), + }); + + useEffect(() => { + if (data === undefined && isError) { + console.error("Error while fetching trees", error); + toast.error( + "Es ist ein Fehler beim Abrufen der Sensor Daten zum Baum: " + treeId, + { + description: error.message, + }, + ); + } + }, [isError, error]); + + const sensorData = useMemo(() => { + return ( + data?.sensorData?.map((d) => ({ + humidity: d.uplinkMessage.decodedPayload.humidity, + battery: d.uplinkMessage.decodedPayload.battery, + trunkHumidity: 113, + timestamp: new Date(d.uplinkMessage.receivedAt), + })) ?? [] + ); + }, [data]); + + const lastSensorData = useMemo( + () => ({ + data: sensorData[sensorData.length - 1], + humidityDiff: + sensorData[sensorData.length - 1]?.humidity - + sensorData[sensorData.length - 2]?.humidity || 0, + batteryDiff: + sensorData[sensorData.length - 1]?.battery - + sensorData[sensorData.length - 2]?.battery || 0, + }), + [sensorData], + ); + + if (!data || isLoading) { + return
Loading...
; + } + + return ( +
+ + {/* Tree Dashboard title and actions */} +
+
+

Baum Cluster {tree?.treeNum}

+

+ {tree?.location.address} ({tree?.location.additionalInfo}) +

+
+ +
+ + + + + +
+
+ + +
+ + Übersicht + + Einsatzplanung + + + Informationen + + Sensor Daten + + +
+ + Letzter Abfrage:{" "} + {format( + dataUpdatedAt, + "dd.MM.yyyy HH:mm:ss", + )} + + + Letzter Sensor Update:{" "} + {format( + lastSensorData.data.timestamp, + "dd.MM.yyyy HH:mm:ss", + )} + +
+
+ + + + + + +
+
+
+ ); +} + +export interface TreeDashboardLayoutProps extends React.PropsWithChildren { } + +// TODO: as layout component in tanstack router +const TreeDashboardLayout = ({ children }: TreeDashboardLayoutProps) => { + return ( +
+
+
+

Dashboard

+
+ +
+ + + + +
+
+ + + +
{children}
+
+ ); +}; From 421608c8cac1dfd874e2e59529d23a13cba2715d Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Sat, 24 Aug 2024 23:19:39 +0200 Subject: [PATCH 03/13] feat: add debug view --- frontend/src/components/layout/Navigation.tsx | 7 ++- frontend/src/routes/debug.tsx | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/debug.tsx diff --git a/frontend/src/components/layout/Navigation.tsx b/frontend/src/components/layout/Navigation.tsx index 0b2dd7b5..2a0dcbf3 100644 --- a/frontend/src/components/layout/Navigation.tsx +++ b/frontend/src/components/layout/Navigation.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftRight, Car, FolderClosed, HardDrive, LogOut, Map, PieChart, Settings, Users } from 'lucide-react'; +import { ArrowLeftRight, Bug, Car, FolderClosed, HardDrive, Info, LogOut, Map, PieChart, Settings, Users } from 'lucide-react'; import * as React from 'react'; import NavLink from '../navigation/NavLink'; import NavHeadline from '../navigation/NavHeadline'; @@ -84,6 +84,11 @@ const Navigation: React.FC = ({ isOpen, openSidebar, closeSideb icon: , to: "/settings", }, + { + label: 'Debug', + icon: , + to: "/debug", + }, { label: 'Ausloggen', icon: , diff --git a/frontend/src/routes/debug.tsx b/frontend/src/routes/debug.tsx new file mode 100644 index 00000000..572e44a1 --- /dev/null +++ b/frontend/src/routes/debug.tsx @@ -0,0 +1,44 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import useAuthStore from "@/store/auth/authStore"; +import useMapStore from "@/store/map/store"; +import { createFileRoute } from "@tanstack/react-router"; +import ReactJson from "react-json-view"; + +export const Route = createFileRoute("/debug")({ + component: Debug, +}); + +function Debug() { + return ( +
+
+

+ Debugging +

+
+ + + Store + Info + + + + + + +
+ ); +} + +const Store = () => { + const authStore = useAuthStore(); + const mapStore = useMapStore(); + + return ( +
+ + +
+ ); +}; + From 3c1302a22f62bf611a63ba4a62b507d4028d47f3 Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Mon, 26 Aug 2024 23:00:17 +0200 Subject: [PATCH 04/13] chore: update auth callback url --- frontend/src/routeTree.gen.ts | 94 ++++++++++++++++++- frontend/src/routes/_protected.tsx | 2 +- .../{callback/keycloak.tsx => callback.tsx} | 4 +- 3 files changed, 96 insertions(+), 4 deletions(-) rename frontend/src/routes/auth/{callback/keycloak.tsx => callback.tsx} (87%) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 0ef4a097..e6c5c969 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,10 +16,15 @@ import { Route as TreeclusterImport } from './routes/treecluster' import { Route as TeamImport } from './routes/team' import { Route as SettingsImport } from './routes/settings' import { Route as SensorsImport } from './routes/sensors' +import { Route as DebugImport } from './routes/debug' +import { Route as ProtectedImport } from './routes/_protected' import { Route as IndexImport } from './routes/index' import { Route as WaypointsIndexImport } from './routes/waypoints/index' import { Route as MapIndexImport } from './routes/map/index' import { Route as WaypointsNewImport } from './routes/waypoints/new' +import { Route as AuthCallbackImport } from './routes/auth/callback' +import { Route as ProtectedInfoImport } from './routes/_protected/info' +import { Route as DashboardTreeTreeIdImport } from './routes/dashboard/tree/$treeId' // Create/Update Routes @@ -48,6 +53,16 @@ const SensorsRoute = SensorsImport.update({ getParentRoute: () => rootRoute, } as any) +const DebugRoute = DebugImport.update({ + path: '/debug', + getParentRoute: () => rootRoute, +} as any) + +const ProtectedRoute = ProtectedImport.update({ + id: '/_protected', + getParentRoute: () => rootRoute, +} as any) + const IndexRoute = IndexImport.update({ path: '/', getParentRoute: () => rootRoute, @@ -68,6 +83,21 @@ const WaypointsNewRoute = WaypointsNewImport.update({ getParentRoute: () => rootRoute, } as any) +const AuthCallbackRoute = AuthCallbackImport.update({ + path: '/auth/callback', + getParentRoute: () => rootRoute, +} as any) + +const ProtectedInfoRoute = ProtectedInfoImport.update({ + path: '/info', + getParentRoute: () => ProtectedRoute, +} as any) + +const DashboardTreeTreeIdRoute = DashboardTreeTreeIdImport.update({ + path: '/dashboard/tree/$treeId', + getParentRoute: () => rootRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -79,6 +109,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexImport parentRoute: typeof rootRoute } + '/_protected': { + id: '/_protected' + path: '' + fullPath: '' + preLoaderRoute: typeof ProtectedImport + parentRoute: typeof rootRoute + } + '/debug': { + id: '/debug' + path: '/debug' + fullPath: '/debug' + preLoaderRoute: typeof DebugImport + parentRoute: typeof rootRoute + } '/sensors': { id: '/sensors' path: '/sensors' @@ -114,6 +158,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof VehiclesImport parentRoute: typeof rootRoute } + '/_protected/info': { + id: '/_protected/info' + path: '/info' + fullPath: '/info' + preLoaderRoute: typeof ProtectedInfoImport + parentRoute: typeof ProtectedImport + } + '/auth/callback': { + id: '/auth/callback' + path: '/auth/callback' + fullPath: '/auth/callback' + preLoaderRoute: typeof AuthCallbackImport + parentRoute: typeof rootRoute + } '/waypoints/new': { id: '/waypoints/new' path: '/waypoints/new' @@ -135,6 +193,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WaypointsIndexImport parentRoute: typeof rootRoute } + '/dashboard/tree/$treeId': { + id: '/dashboard/tree/$treeId' + path: '/dashboard/tree/$treeId' + fullPath: '/dashboard/tree/$treeId' + preLoaderRoute: typeof DashboardTreeTreeIdImport + parentRoute: typeof rootRoute + } } } @@ -142,14 +207,18 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren({ IndexRoute, + ProtectedRoute: ProtectedRoute.addChildren({ ProtectedInfoRoute }), + DebugRoute, SensorsRoute, SettingsRoute, TeamRoute, TreeclusterRoute, VehiclesRoute, + AuthCallbackRoute, WaypointsNewRoute, MapIndexRoute, WaypointsIndexRoute, + DashboardTreeTreeIdRoute, }) /* prettier-ignore-end */ @@ -161,19 +230,32 @@ export const routeTree = rootRoute.addChildren({ "filePath": "__root.tsx", "children": [ "/", + "/_protected", + "/debug", "/sensors", "/settings", "/team", "/treecluster", "/vehicles", + "/auth/callback", "/waypoints/new", "/map/", - "/waypoints/" + "/waypoints/", + "/dashboard/tree/$treeId" ] }, "/": { "filePath": "index.tsx" }, + "/_protected": { + "filePath": "_protected.tsx", + "children": [ + "/_protected/info" + ] + }, + "/debug": { + "filePath": "debug.tsx" + }, "/sensors": { "filePath": "sensors.tsx" }, @@ -189,6 +271,13 @@ export const routeTree = rootRoute.addChildren({ "/vehicles": { "filePath": "vehicles.tsx" }, + "/_protected/info": { + "filePath": "_protected/info.tsx", + "parent": "/_protected" + }, + "/auth/callback": { + "filePath": "auth/callback.tsx" + }, "/waypoints/new": { "filePath": "waypoints/new.tsx" }, @@ -197,6 +286,9 @@ export const routeTree = rootRoute.addChildren({ }, "/waypoints/": { "filePath": "waypoints/index.tsx" + }, + "/dashboard/tree/$treeId": { + "filePath": "dashboard/tree/$treeId.tsx" } } } diff --git a/frontend/src/routes/_protected.tsx b/frontend/src/routes/_protected.tsx index 314251ac..417b9e82 100644 --- a/frontend/src/routes/_protected.tsx +++ b/frontend/src/routes/_protected.tsx @@ -8,7 +8,7 @@ export const Route = createFileRoute('/_protected')({ const currentPath = window.location.pathname if (!isAuthenticated) { const loginUrl = await loginApi.v1LoginGet({ - redirectUrl: `${window.location.origin}/auth/callback/keycloak?redirect=${currentPath}` + redirectUrl: `${window.location.origin}/auth/callback?redirect=${currentPath}` }).then((res) => res.loginUrl) window.location.href = loginUrl diff --git a/frontend/src/routes/auth/callback/keycloak.tsx b/frontend/src/routes/auth/callback.tsx similarity index 87% rename from frontend/src/routes/auth/callback/keycloak.tsx rename to frontend/src/routes/auth/callback.tsx index 2ddea2a9..7ca7cbd3 100644 --- a/frontend/src/routes/auth/callback/keycloak.tsx +++ b/frontend/src/routes/auth/callback.tsx @@ -13,13 +13,13 @@ const authSearchParamsSchema = z.object({ redirect: z.string(), }); -export const Route = createFileRoute("/auth/callback/keycloak")({ +export const Route = createFileRoute("/auth/callback")({ validateSearch: authSearchParamsSchema, loaderDeps: ({ search: { code } }) => ({ code }), beforeLoad: async ({ search: { code, redirect } }) => { const token = await loginApi .v1TokenPost({ - redirectUrl: `${window.location.origin}/auth/callback/keycloak?redirect=${redirect}`, + redirectUrl: `${window.location.origin}/auth/callback?redirect=${redirect}`, body: { code, }, From da41ff0849fff3313efe2e5bb32629f5e1206557 Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Wed, 4 Sep 2024 19:51:11 +0200 Subject: [PATCH 05/13] feat: move all pages to protected and refactor stores --- frontend/src/components/Map.tsx | 2 +- frontend/src/context/TreeDataContext.tsx | 4 +- frontend/src/hooks/useAuthHeader.ts | 8 + frontend/src/lib/types/keycloak.ts | 20 ++ frontend/src/lib/utils.ts | 6 + frontend/src/routeTree.gen.ts | 322 +++++++++--------- frontend/src/routes/_protected.tsx | 8 +- .../dashboard/tree/$treeId.tsx | 9 +- .../src/routes/{ => _protected}/debug.tsx | 6 +- .../src/routes/{ => _protected}/index.tsx | 2 +- frontend/src/routes/_protected/info.tsx | 16 +- frontend/src/routes/_protected/map/index.tsx | 86 +++++ .../src/routes/{ => _protected}/sensors.tsx | 2 +- .../src/routes/{ => _protected}/settings.tsx | 2 +- frontend/src/routes/{ => _protected}/team.tsx | 8 +- .../routes/{ => _protected}/treecluster.tsx | 2 +- .../src/routes/{ => _protected}/vehicles.tsx | 12 +- .../{ => _protected}/waypoints/index.tsx | 14 +- .../routes/{ => _protected}/waypoints/new.tsx | 10 +- frontend/src/routes/auth/callback.tsx | 28 +- frontend/src/routes/map/index.tsx | 240 ------------- frontend/src/store/auth/authStore.ts | 43 +-- frontend/src/store/auth/types.ts | 14 + frontend/src/store/map/mapSlice.ts | 28 -- frontend/src/store/map/mapStore.ts | 17 + frontend/src/store/map/store.ts | 21 -- frontend/src/store/map/tooltipSlice.ts | 31 -- frontend/src/store/map/types.ts | 14 + frontend/src/store/sidePanelStore.ts | 25 -- frontend/src/store/store.ts | 41 +++ frontend/src/store/user/types.ts | 16 + frontend/src/store/user/userStore.ts | 25 ++ 32 files changed, 491 insertions(+), 591 deletions(-) create mode 100644 frontend/src/hooks/useAuthHeader.ts create mode 100644 frontend/src/lib/types/keycloak.ts rename frontend/src/routes/{ => _protected}/dashboard/tree/$treeId.tsx (96%) rename frontend/src/routes/{ => _protected}/debug.tsx (87%) rename frontend/src/routes/{ => _protected}/index.tsx (97%) create mode 100644 frontend/src/routes/_protected/map/index.tsx rename frontend/src/routes/{ => _protected}/sensors.tsx (93%) rename frontend/src/routes/{ => _protected}/settings.tsx (92%) rename frontend/src/routes/{ => _protected}/team.tsx (96%) rename frontend/src/routes/{ => _protected}/treecluster.tsx (91%) rename frontend/src/routes/{ => _protected}/vehicles.tsx (94%) rename frontend/src/routes/{ => _protected}/waypoints/index.tsx (96%) rename frontend/src/routes/{ => _protected}/waypoints/new.tsx (96%) delete mode 100644 frontend/src/routes/map/index.tsx create mode 100644 frontend/src/store/auth/types.ts delete mode 100644 frontend/src/store/map/mapSlice.ts create mode 100644 frontend/src/store/map/mapStore.ts delete mode 100644 frontend/src/store/map/store.ts delete mode 100644 frontend/src/store/map/tooltipSlice.ts create mode 100644 frontend/src/store/map/types.ts delete mode 100644 frontend/src/store/sidePanelStore.ts create mode 100644 frontend/src/store/store.ts create mode 100644 frontend/src/store/user/types.ts create mode 100644 frontend/src/store/user/userStore.ts diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index 8b4b4354..58244873 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -1,6 +1,6 @@ import { MapContainer, TileLayer } from "react-leaflet"; import React from "react"; -import useMapStore from "@/store/map/store"; +import useMapStore from "@/store/store"; export interface MapProps extends React.PropsWithChildren { width?: string; diff --git a/frontend/src/context/TreeDataContext.tsx b/frontend/src/context/TreeDataContext.tsx index 05a8d3b5..cad3346e 100644 --- a/frontend/src/context/TreeDataContext.tsx +++ b/frontend/src/context/TreeDataContext.tsx @@ -1,5 +1,5 @@ import { treeApi } from "@/api/backendApi"; -import useAuthStore from "@/store/auth/authStore"; +import { useAuthHeader } from "@/hooks/useAuthHeader"; import { Tree } from "@green-ecolution/backend-client"; import { useQuery } from "@tanstack/react-query"; import { createContext, useContext, useEffect } from "react"; @@ -16,7 +16,7 @@ export interface TreeDataContextProviderProps extends React.PropsWithChildren {} export const TreeDataContextProvider = ({ children, }: TreeDataContextProviderProps) => { - const {apiHeader} = useAuthStore(state => ({apiHeader: state.apiHeader})); + const apiHeader = useAuthHeader(); const { data, isError, error } = useQuery({ queryKey: ["trees"], queryFn: () => diff --git a/frontend/src/hooks/useAuthHeader.ts b/frontend/src/hooks/useAuthHeader.ts new file mode 100644 index 00000000..a07f991d --- /dev/null +++ b/frontend/src/hooks/useAuthHeader.ts @@ -0,0 +1,8 @@ +import useStore from "@/store/store"; + +export const useAuthHeader = () => { + const token = useStore((state) => state.auth.token); + + // TODO: Add logic to refresh token if expired + return token ? `Bearer ${token}` : ""; +}; diff --git a/frontend/src/lib/types/keycloak.ts b/frontend/src/lib/types/keycloak.ts new file mode 100644 index 00000000..1ac7b404 --- /dev/null +++ b/frontend/src/lib/types/keycloak.ts @@ -0,0 +1,20 @@ + +export type KeycloakJWT = { + jti: string; + exp: number; + nbf: number; + iat: number; + iss: string; + aud: string; + sub: string; + typ: string; + azp: string; + session_state: string; + acr: string; + email_verified: boolean; + name: string; + preferred_username: string; + given_name: string; + family_name: string; + email: string; +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index d084ccad..0589414e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,9 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function decodeJWT(token: string): T { + const payload = token.split(".")[1] + const decodedPayload = atob(payload) + return JSON.parse(decodedPayload) +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index e6c5c969..7efed9e1 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -11,104 +11,98 @@ // Import Routes import { Route as rootRoute } from './routes/__root' -import { Route as VehiclesImport } from './routes/vehicles' -import { Route as TreeclusterImport } from './routes/treecluster' -import { Route as TeamImport } from './routes/team' -import { Route as SettingsImport } from './routes/settings' -import { Route as SensorsImport } from './routes/sensors' -import { Route as DebugImport } from './routes/debug' import { Route as ProtectedImport } from './routes/_protected' -import { Route as IndexImport } from './routes/index' -import { Route as WaypointsIndexImport } from './routes/waypoints/index' -import { Route as MapIndexImport } from './routes/map/index' -import { Route as WaypointsNewImport } from './routes/waypoints/new' +import { Route as ProtectedIndexImport } from './routes/_protected/index' import { Route as AuthCallbackImport } from './routes/auth/callback' +import { Route as ProtectedVehiclesImport } from './routes/_protected/vehicles' +import { Route as ProtectedTreeclusterImport } from './routes/_protected/treecluster' +import { Route as ProtectedTeamImport } from './routes/_protected/team' +import { Route as ProtectedSettingsImport } from './routes/_protected/settings' +import { Route as ProtectedSensorsImport } from './routes/_protected/sensors' import { Route as ProtectedInfoImport } from './routes/_protected/info' -import { Route as DashboardTreeTreeIdImport } from './routes/dashboard/tree/$treeId' +import { Route as ProtectedDebugImport } from './routes/_protected/debug' +import { Route as ProtectedWaypointsIndexImport } from './routes/_protected/waypoints/index' +import { Route as ProtectedMapIndexImport } from './routes/_protected/map/index' +import { Route as ProtectedWaypointsNewImport } from './routes/_protected/waypoints/new' +import { Route as ProtectedDashboardTreeTreeIdImport } from './routes/_protected/dashboard/tree/$treeId' // Create/Update Routes -const VehiclesRoute = VehiclesImport.update({ - path: '/vehicles', +const ProtectedRoute = ProtectedImport.update({ + id: '/_protected', getParentRoute: () => rootRoute, } as any) -const TreeclusterRoute = TreeclusterImport.update({ - path: '/treecluster', - getParentRoute: () => rootRoute, +const ProtectedIndexRoute = ProtectedIndexImport.update({ + path: '/', + getParentRoute: () => ProtectedRoute, } as any) -const TeamRoute = TeamImport.update({ - path: '/team', +const AuthCallbackRoute = AuthCallbackImport.update({ + path: '/auth/callback', getParentRoute: () => rootRoute, } as any) -const SettingsRoute = SettingsImport.update({ - path: '/settings', - getParentRoute: () => rootRoute, +const ProtectedVehiclesRoute = ProtectedVehiclesImport.update({ + path: '/vehicles', + getParentRoute: () => ProtectedRoute, } as any) -const SensorsRoute = SensorsImport.update({ - path: '/sensors', - getParentRoute: () => rootRoute, +const ProtectedTreeclusterRoute = ProtectedTreeclusterImport.update({ + path: '/treecluster', + getParentRoute: () => ProtectedRoute, } as any) -const DebugRoute = DebugImport.update({ - path: '/debug', - getParentRoute: () => rootRoute, +const ProtectedTeamRoute = ProtectedTeamImport.update({ + path: '/team', + getParentRoute: () => ProtectedRoute, } as any) -const ProtectedRoute = ProtectedImport.update({ - id: '/_protected', - getParentRoute: () => rootRoute, +const ProtectedSettingsRoute = ProtectedSettingsImport.update({ + path: '/settings', + getParentRoute: () => ProtectedRoute, } as any) -const IndexRoute = IndexImport.update({ - path: '/', - getParentRoute: () => rootRoute, +const ProtectedSensorsRoute = ProtectedSensorsImport.update({ + path: '/sensors', + getParentRoute: () => ProtectedRoute, } as any) -const WaypointsIndexRoute = WaypointsIndexImport.update({ - path: '/waypoints/', - getParentRoute: () => rootRoute, +const ProtectedInfoRoute = ProtectedInfoImport.update({ + path: '/info', + getParentRoute: () => ProtectedRoute, } as any) -const MapIndexRoute = MapIndexImport.update({ - path: '/map/', - getParentRoute: () => rootRoute, +const ProtectedDebugRoute = ProtectedDebugImport.update({ + path: '/debug', + getParentRoute: () => ProtectedRoute, } as any) -const WaypointsNewRoute = WaypointsNewImport.update({ - path: '/waypoints/new', - getParentRoute: () => rootRoute, +const ProtectedWaypointsIndexRoute = ProtectedWaypointsIndexImport.update({ + path: '/waypoints/', + getParentRoute: () => ProtectedRoute, } as any) -const AuthCallbackRoute = AuthCallbackImport.update({ - path: '/auth/callback', - getParentRoute: () => rootRoute, +const ProtectedMapIndexRoute = ProtectedMapIndexImport.update({ + path: '/map/', + getParentRoute: () => ProtectedRoute, } as any) -const ProtectedInfoRoute = ProtectedInfoImport.update({ - path: '/info', +const ProtectedWaypointsNewRoute = ProtectedWaypointsNewImport.update({ + path: '/waypoints/new', getParentRoute: () => ProtectedRoute, } as any) -const DashboardTreeTreeIdRoute = DashboardTreeTreeIdImport.update({ - path: '/dashboard/tree/$treeId', - getParentRoute: () => rootRoute, -} as any) +const ProtectedDashboardTreeTreeIdRoute = + ProtectedDashboardTreeTreeIdImport.update({ + path: '/dashboard/tree/$treeId', + getParentRoute: () => ProtectedRoute, + } as any) // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexImport - parentRoute: typeof rootRoute - } '/_protected': { id: '/_protected' path: '' @@ -116,53 +110,53 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedImport parentRoute: typeof rootRoute } - '/debug': { - id: '/debug' + '/_protected/debug': { + id: '/_protected/debug' path: '/debug' fullPath: '/debug' - preLoaderRoute: typeof DebugImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedDebugImport + parentRoute: typeof ProtectedImport + } + '/_protected/info': { + id: '/_protected/info' + path: '/info' + fullPath: '/info' + preLoaderRoute: typeof ProtectedInfoImport + parentRoute: typeof ProtectedImport } - '/sensors': { - id: '/sensors' + '/_protected/sensors': { + id: '/_protected/sensors' path: '/sensors' fullPath: '/sensors' - preLoaderRoute: typeof SensorsImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedSensorsImport + parentRoute: typeof ProtectedImport } - '/settings': { - id: '/settings' + '/_protected/settings': { + id: '/_protected/settings' path: '/settings' fullPath: '/settings' - preLoaderRoute: typeof SettingsImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedSettingsImport + parentRoute: typeof ProtectedImport } - '/team': { - id: '/team' + '/_protected/team': { + id: '/_protected/team' path: '/team' fullPath: '/team' - preLoaderRoute: typeof TeamImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedTeamImport + parentRoute: typeof ProtectedImport } - '/treecluster': { - id: '/treecluster' + '/_protected/treecluster': { + id: '/_protected/treecluster' path: '/treecluster' fullPath: '/treecluster' - preLoaderRoute: typeof TreeclusterImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedTreeclusterImport + parentRoute: typeof ProtectedImport } - '/vehicles': { - id: '/vehicles' + '/_protected/vehicles': { + id: '/_protected/vehicles' path: '/vehicles' fullPath: '/vehicles' - preLoaderRoute: typeof VehiclesImport - parentRoute: typeof rootRoute - } - '/_protected/info': { - id: '/_protected/info' - path: '/info' - fullPath: '/info' - preLoaderRoute: typeof ProtectedInfoImport + preLoaderRoute: typeof ProtectedVehiclesImport parentRoute: typeof ProtectedImport } '/auth/callback': { @@ -172,33 +166,40 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthCallbackImport parentRoute: typeof rootRoute } - '/waypoints/new': { - id: '/waypoints/new' + '/_protected/': { + id: '/_protected/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof ProtectedIndexImport + parentRoute: typeof ProtectedImport + } + '/_protected/waypoints/new': { + id: '/_protected/waypoints/new' path: '/waypoints/new' fullPath: '/waypoints/new' - preLoaderRoute: typeof WaypointsNewImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedWaypointsNewImport + parentRoute: typeof ProtectedImport } - '/map/': { - id: '/map/' + '/_protected/map/': { + id: '/_protected/map/' path: '/map' fullPath: '/map' - preLoaderRoute: typeof MapIndexImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedMapIndexImport + parentRoute: typeof ProtectedImport } - '/waypoints/': { - id: '/waypoints/' + '/_protected/waypoints/': { + id: '/_protected/waypoints/' path: '/waypoints' fullPath: '/waypoints' - preLoaderRoute: typeof WaypointsIndexImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedWaypointsIndexImport + parentRoute: typeof ProtectedImport } - '/dashboard/tree/$treeId': { - id: '/dashboard/tree/$treeId' + '/_protected/dashboard/tree/$treeId': { + id: '/_protected/dashboard/tree/$treeId' path: '/dashboard/tree/$treeId' fullPath: '/dashboard/tree/$treeId' - preLoaderRoute: typeof DashboardTreeTreeIdImport - parentRoute: typeof rootRoute + preLoaderRoute: typeof ProtectedDashboardTreeTreeIdImport + parentRoute: typeof ProtectedImport } } } @@ -206,19 +207,21 @@ declare module '@tanstack/react-router' { // Create and export the route tree export const routeTree = rootRoute.addChildren({ - IndexRoute, - ProtectedRoute: ProtectedRoute.addChildren({ ProtectedInfoRoute }), - DebugRoute, - SensorsRoute, - SettingsRoute, - TeamRoute, - TreeclusterRoute, - VehiclesRoute, + ProtectedRoute: ProtectedRoute.addChildren({ + ProtectedDebugRoute, + ProtectedInfoRoute, + ProtectedSensorsRoute, + ProtectedSettingsRoute, + ProtectedTeamRoute, + ProtectedTreeclusterRoute, + ProtectedVehiclesRoute, + ProtectedIndexRoute, + ProtectedWaypointsNewRoute, + ProtectedMapIndexRoute, + ProtectedWaypointsIndexRoute, + ProtectedDashboardTreeTreeIdRoute, + }), AuthCallbackRoute, - WaypointsNewRoute, - MapIndexRoute, - WaypointsIndexRoute, - DashboardTreeTreeIdRoute, }) /* prettier-ignore-end */ @@ -229,66 +232,77 @@ export const routeTree = rootRoute.addChildren({ "__root__": { "filePath": "__root.tsx", "children": [ - "/", "/_protected", - "/debug", - "/sensors", - "/settings", - "/team", - "/treecluster", - "/vehicles", - "/auth/callback", - "/waypoints/new", - "/map/", - "/waypoints/", - "/dashboard/tree/$treeId" + "/auth/callback" ] }, - "/": { - "filePath": "index.tsx" - }, "/_protected": { "filePath": "_protected.tsx", "children": [ - "/_protected/info" + "/_protected/debug", + "/_protected/info", + "/_protected/sensors", + "/_protected/settings", + "/_protected/team", + "/_protected/treecluster", + "/_protected/vehicles", + "/_protected/", + "/_protected/waypoints/new", + "/_protected/map/", + "/_protected/waypoints/", + "/_protected/dashboard/tree/$treeId" ] }, - "/debug": { - "filePath": "debug.tsx" + "/_protected/debug": { + "filePath": "_protected/debug.tsx", + "parent": "/_protected" }, - "/sensors": { - "filePath": "sensors.tsx" + "/_protected/info": { + "filePath": "_protected/info.tsx", + "parent": "/_protected" }, - "/settings": { - "filePath": "settings.tsx" + "/_protected/sensors": { + "filePath": "_protected/sensors.tsx", + "parent": "/_protected" }, - "/team": { - "filePath": "team.tsx" + "/_protected/settings": { + "filePath": "_protected/settings.tsx", + "parent": "/_protected" }, - "/treecluster": { - "filePath": "treecluster.tsx" + "/_protected/team": { + "filePath": "_protected/team.tsx", + "parent": "/_protected" }, - "/vehicles": { - "filePath": "vehicles.tsx" + "/_protected/treecluster": { + "filePath": "_protected/treecluster.tsx", + "parent": "/_protected" }, - "/_protected/info": { - "filePath": "_protected/info.tsx", + "/_protected/vehicles": { + "filePath": "_protected/vehicles.tsx", "parent": "/_protected" }, "/auth/callback": { "filePath": "auth/callback.tsx" }, - "/waypoints/new": { - "filePath": "waypoints/new.tsx" + "/_protected/": { + "filePath": "_protected/index.tsx", + "parent": "/_protected" + }, + "/_protected/waypoints/new": { + "filePath": "_protected/waypoints/new.tsx", + "parent": "/_protected" }, - "/map/": { - "filePath": "map/index.tsx" + "/_protected/map/": { + "filePath": "_protected/map/index.tsx", + "parent": "/_protected" }, - "/waypoints/": { - "filePath": "waypoints/index.tsx" + "/_protected/waypoints/": { + "filePath": "_protected/waypoints/index.tsx", + "parent": "/_protected" }, - "/dashboard/tree/$treeId": { - "filePath": "dashboard/tree/$treeId.tsx" + "/_protected/dashboard/tree/$treeId": { + "filePath": "_protected/dashboard/tree/$treeId.tsx", + "parent": "/_protected" } } } diff --git a/frontend/src/routes/_protected.tsx b/frontend/src/routes/_protected.tsx index 417b9e82..22658238 100644 --- a/frontend/src/routes/_protected.tsx +++ b/frontend/src/routes/_protected.tsx @@ -1,14 +1,14 @@ import { loginApi } from '@/api/backendApi' -import useAuthStore from '@/store/auth/authStore' +import useAuthStore from '@/store/store' import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_protected')({ beforeLoad: async () => { - const isAuthenticated = useAuthStore.getState().isAuthenticated - const currentPath = window.location.pathname + const isAuthenticated = useAuthStore.getState().auth.isAuthenticated + const currentPath = (location.pathname+location.search) if (!isAuthenticated) { const loginUrl = await loginApi.v1LoginGet({ - redirectUrl: `${window.location.origin}/auth/callback?redirect=${currentPath}` + redirectUrl: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(currentPath)}` }).then((res) => res.loginUrl) window.location.href = loginUrl diff --git a/frontend/src/routes/dashboard/tree/$treeId.tsx b/frontend/src/routes/_protected/dashboard/tree/$treeId.tsx similarity index 96% rename from frontend/src/routes/dashboard/tree/$treeId.tsx rename to frontend/src/routes/_protected/dashboard/tree/$treeId.tsx index 4d3a0570..da886c40 100644 --- a/frontend/src/routes/dashboard/tree/$treeId.tsx +++ b/frontend/src/routes/_protected/dashboard/tree/$treeId.tsx @@ -20,22 +20,21 @@ import TreeOverviewDashboard from "@/components/dashboard/tree/overview"; import TreeSensorDashboard from "@/components/dashboard/tree/sensorView"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { format } from "date-fns"; -import useAuthStore from "@/store/auth/authStore"; - -export const Route = createFileRoute("/dashboard/tree/$treeId")({ +import { useAuthHeader } from "@/hooks/useAuthHeader"; +export const Route = createFileRoute("/_protected/dashboard/tree/$treeId")({ component: TreeDashboard, }); function TreeDashboard() { const { treeId } = Route.useParams(); const tree = useTree(treeId); - const {apiHeader} = useAuthStore(state => ({apiHeader: state.apiHeader})); + const authorization = useAuthHeader(); const { data, isError, error, isLoading, dataUpdatedAt } = useQuery({ queryKey: ["tree_prediction", treeId], refetchInterval: 10000, queryFn: () => - treeApi.getTreePredictionById({ treeID: treeId, sensorData: true, authorization: apiHeader}), + treeApi.getTreePredictionById({ treeID: treeId, sensorData: true, authorization}), }); useEffect(() => { diff --git a/frontend/src/routes/debug.tsx b/frontend/src/routes/_protected/debug.tsx similarity index 87% rename from frontend/src/routes/debug.tsx rename to frontend/src/routes/_protected/debug.tsx index 572e44a1..3ffa37f2 100644 --- a/frontend/src/routes/debug.tsx +++ b/frontend/src/routes/_protected/debug.tsx @@ -1,13 +1,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import useAuthStore from "@/store/auth/authStore"; -import useMapStore from "@/store/map/store"; +import { useAuthStore, useMapStore } from "@/store/store"; import { createFileRoute } from "@tanstack/react-router"; import ReactJson from "react-json-view"; -export const Route = createFileRoute("/debug")({ +export const Route = createFileRoute("/_protected/debug")({ component: Debug, }); + function Debug() { return (
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/_protected/index.tsx similarity index 97% rename from frontend/src/routes/index.tsx rename to frontend/src/routes/_protected/index.tsx index dc87352a..6b69a28a 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/_protected/index.tsx @@ -1,7 +1,7 @@ import DashboardCard from '@/components/general/cards/DashboardCard'; import { createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/')({ +export const Route = createFileRoute('/_protected/')({ component: Dashboard, }) diff --git a/frontend/src/routes/_protected/info.tsx b/frontend/src/routes/_protected/info.tsx index 505e4ad7..729b2dbc 100644 --- a/frontend/src/routes/_protected/info.tsx +++ b/frontend/src/routes/_protected/info.tsx @@ -1,5 +1,5 @@ -import useAuthStore from "@/store/auth/authStore"; import { infoApi } from "@/api/backendApi"; +import { useAuthHeader } from "@/hooks/useAuthHeader"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; @@ -8,26 +8,16 @@ export const Route = createFileRoute("/_protected/info")({ }); function Info() { - const {apiHeader: authorization} = useAuthStore(state => ({apiHeader: state.apiHeader})) + const authorization = useAuthHeader(); const { data, isFetching } = useQuery({ queryKey: ["info"], - queryFn: () => infoApi.getAppInfo({ - authorization - }), + queryFn: () => infoApi.getAppInfo({ authorization }), }); - const { isAuthenticated, token } = useAuthStore((state) => ({ - isAuthenticated: state.isAuthenticated, - token: state.token, - })); - return (

App Info

-

Authenticated: {isAuthenticated ? "Yes" : "No"}

-

Token: {token ? "Yes" : "No"}

-
{JSON.stringify(token, null, 2)}
{isFetching ? (

Loading...

) : ( diff --git a/frontend/src/routes/_protected/map/index.tsx b/frontend/src/routes/_protected/map/index.tsx new file mode 100644 index 00000000..718bd008 --- /dev/null +++ b/frontend/src/routes/_protected/map/index.tsx @@ -0,0 +1,86 @@ +import Map from "@/components/Map"; +import MapMarker, { TreeIcon } from "@/components/MapMarker"; +import { useTrees } from "@/context/TreeDataContext"; +import useMapStore from "@/store/store"; +import { Tree } from "@green-ecolution/backend-client"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useMapEvents } from "react-leaflet/hooks"; +import { z } from "zod"; + +const mapSearchParamsSchema = z.object({ + selected: z.string().optional(), + lat: z.number().catch(useMapStore.getState().map.center[0]), + lng: z.number().catch(useMapStore.getState().map.center[1]), + zoom: z + .number() + .int() + .max(useMapStore.getState().map.maxZoom) + .min(useMapStore.getState().map.minZoom) + .catch(useMapStore.getState().map.minZoom), +}); + +export const Route = createFileRoute("/_protected/map/")({ + component: MapView, + validateSearch: mapSearchParamsSchema, + loaderDeps: ({ search: { lat, lng, zoom } }) => ({ lat, lng, zoom }), + loader: ({ deps: { lat, lng, zoom } }) => { + useMapStore.setState((state) => ({ + map: { ...state.map, center: [lat, lng], zoom }, + })); + }, +}); + +function MapView() { + const trees = useTrees(); + + return ( +
+ + + + +
+ ); +} + +const MapConroller = () => { + const navigate = useNavigate({ from: Route.fullPath }); + const { setCenter, setZoom } = useMapStore((state) => ({ + setCenter: state.map.setCenter, + setZoom: state.map.setZoom, + })); + const map = useMapEvents({ + moveend: () => { + const center = map.getCenter(); + const zoom = map.getZoom(); + setCenter([center.lat, center.lng]); + setZoom(zoom); + navigate({ + search: (prev) => ({ ...prev, lat: center.lat, lng: center.lng, zoom }), + }); + }, + }); + + return null; +}; + +const TreeMarker = ({ trees }: { trees: Tree[] }) => { + const treeMarkers = useMemo( + () => + trees.map((tree) => ( + { + // navigate to tree detail page + }} + /> + )), + [trees], + ); + + return <>{treeMarkers}; +}; + diff --git a/frontend/src/routes/sensors.tsx b/frontend/src/routes/_protected/sensors.tsx similarity index 93% rename from frontend/src/routes/sensors.tsx rename to frontend/src/routes/_protected/sensors.tsx index 525e5483..86530033 100644 --- a/frontend/src/routes/sensors.tsx +++ b/frontend/src/routes/_protected/sensors.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' -export const Route = createFileRoute('/sensors')({ +export const Route = createFileRoute('/_protected/sensors')({ component: Sensors, }) diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/_protected/settings.tsx similarity index 92% rename from frontend/src/routes/settings.tsx rename to frontend/src/routes/_protected/settings.tsx index 3158a49f..3c56e5bc 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/_protected/settings.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/settings")({ +export const Route = createFileRoute("/_protected/settings")({ component: Settings, }); diff --git a/frontend/src/routes/team.tsx b/frontend/src/routes/_protected/team.tsx similarity index 96% rename from frontend/src/routes/team.tsx rename to frontend/src/routes/_protected/team.tsx index 6e7b8cd5..b500c74b 100644 --- a/frontend/src/routes/team.tsx +++ b/frontend/src/routes/_protected/team.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Separator } from "../components/ui/separator"; +import { Separator } from "../../components/ui/separator"; import { Table, TableBody, @@ -8,16 +8,16 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Button } from "../components/ui/button"; +import { Button } from "@/components/ui/button"; import { Edit, PlusCircleIcon, Trash, Filter } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger, -} from "../components/ui/popover"; +} from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; -export const Route = createFileRoute("/team")({ +export const Route = createFileRoute("/_protected/team")({ component: Team, }); diff --git a/frontend/src/routes/treecluster.tsx b/frontend/src/routes/_protected/treecluster.tsx similarity index 91% rename from frontend/src/routes/treecluster.tsx rename to frontend/src/routes/_protected/treecluster.tsx index a3c8e298..6f5ebaaf 100644 --- a/frontend/src/routes/treecluster.tsx +++ b/frontend/src/routes/_protected/treecluster.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/treecluster")({ +export const Route = createFileRoute("/_protected/treecluster")({ component: Treecluster, }); diff --git a/frontend/src/routes/vehicles.tsx b/frontend/src/routes/_protected/vehicles.tsx similarity index 94% rename from frontend/src/routes/vehicles.tsx rename to frontend/src/routes/_protected/vehicles.tsx index 02b5587d..c10eb9cf 100644 --- a/frontend/src/routes/vehicles.tsx +++ b/frontend/src/routes/_protected/vehicles.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Separator } from "../components/ui/separator"; +import { Separator } from "../../components/ui/separator"; import { Table, TableBody, @@ -7,17 +7,17 @@ import { TableHead, TableHeader, TableRow, -} from "../components/ui/table"; -import { Button } from "../components/ui/button"; +} from "../../components/ui/table"; +import { Button } from "../../components/ui/button"; import { Edit, PlusCircleIcon, Trash, Filter } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger, -} from "../components/ui/popover"; -import { Checkbox } from "../components/ui/checkbox"; +} from "../../components/ui/popover"; +import { Checkbox } from "../../components/ui/checkbox"; -export const Route = createFileRoute("/vehicles")({ +export const Route = createFileRoute("/_protected/vehicles")({ component: Vehicles, }); diff --git a/frontend/src/routes/waypoints/index.tsx b/frontend/src/routes/_protected/waypoints/index.tsx similarity index 96% rename from frontend/src/routes/waypoints/index.tsx rename to frontend/src/routes/_protected/waypoints/index.tsx index a61a283b..a92e2a01 100644 --- a/frontend/src/routes/waypoints/index.tsx +++ b/frontend/src/routes/_protected/waypoints/index.tsx @@ -1,14 +1,14 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { Button } from "../../components/ui/button"; +import { Button } from "../../../components/ui/button"; import { Check, Droplet, Maximize2, PlusCircleIcon, Trees } from "lucide-react"; -import { Separator } from "../../components/ui/separator"; -import { cn } from "../../lib/utils"; +import { Separator } from "../../../components/ui/separator"; +import { cn } from "../../../lib/utils"; import { useEffect, useMemo, useRef, useState } from "react"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, -} from "../../components/ui/resizable"; +} from "../../../components/ui/resizable"; import { MapContainer, Marker, @@ -23,16 +23,16 @@ import { CardDescription, CardHeader, CardTitle, -} from "../../components/ui/card"; +} from "../../../components/ui/card"; import { Tooltip, TooltipContent, TooltipTrigger, -} from "../../components/ui/tooltip"; +} from "../../../components/ui/tooltip"; import { FakeTree, useFakeTrees } from "@/context/FakeTreeDataContext"; import { TreeIcon } from "@/components/MapMarker"; -export const Route = createFileRoute("/waypoints/")({ +export const Route = createFileRoute("/_protected/waypoints/")({ component: Waypoints, }); diff --git a/frontend/src/routes/waypoints/new.tsx b/frontend/src/routes/_protected/waypoints/new.tsx similarity index 96% rename from frontend/src/routes/waypoints/new.tsx rename to frontend/src/routes/_protected/waypoints/new.tsx index 29c3d4b4..a6997ab2 100644 --- a/frontend/src/routes/waypoints/new.tsx +++ b/frontend/src/routes/_protected/waypoints/new.tsx @@ -5,7 +5,7 @@ import { LeafletMouseEvent, LatLngExpression, } from "leaflet"; -import MapHeader from "../../components/MapHeader"; +import MapHeader from "../../../components/MapHeader"; import { Card, CardContent, @@ -13,15 +13,15 @@ import { CardFooter, CardHeader, CardTitle, -} from "../../components/ui/card"; +} from "../../../components/ui/card"; import { ForwardedRef, forwardRef, useMemo, useRef, useState } from "react"; -import { Button } from "../../components/ui/button"; +import { Button } from "../../../components/ui/button"; import { Eye, Trash } from "lucide-react"; -import { ScrollArea } from "../../components/ui/scroll-area"; +import { ScrollArea } from "../../../components/ui/scroll-area"; import { FakeTree, useFakeTrees } from "@/context/FakeTreeDataContext"; import { TreeIcon } from "@/components/MapMarker"; -export const Route = createFileRoute("/waypoints/new")({ +export const Route = createFileRoute("/_protected/waypoints/new")({ component: NewWaypoint, }); diff --git a/frontend/src/routes/auth/callback.tsx b/frontend/src/routes/auth/callback.tsx index 7ca7cbd3..b6f62afd 100644 --- a/frontend/src/routes/auth/callback.tsx +++ b/frontend/src/routes/auth/callback.tsx @@ -1,5 +1,7 @@ import { loginApi } from "@/api/backendApi"; -import useAuthStore from "@/store/auth/authStore"; +import { KeycloakJWT } from "@/lib/types/keycloak"; +import { decodeJWT } from "@/lib/utils"; +import useStore from "@/store/store"; import { createFileRoute, redirect as routerRedirect, @@ -19,7 +21,7 @@ export const Route = createFileRoute("/auth/callback")({ beforeLoad: async ({ search: { code, redirect } }) => { const token = await loginApi .v1TokenPost({ - redirectUrl: `${window.location.origin}/auth/callback?redirect=${redirect}`, + redirectUrl: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirect)}`, body: { code, }, @@ -34,14 +36,22 @@ export const Route = createFileRoute("/auth/callback")({ throw new Error("Error while fetching token"); } - console.log("token", token); - useAuthStore.setState((state) => ({ - ...state, - isAuthenticated: true, - token: token, - apiHeader: `${token.tokenType} ${token.accessToken}`, - })); + useStore.setState((state) => { + state.auth.isAuthenticated = true; + state.auth.token = token; + }); + + const jwtInfo = decodeJWT(token.accessToken); + + if (jwtInfo) { + useStore.setState((state) => { + state.user.email = jwtInfo.email; + state.user.username = jwtInfo.preferred_username; + state.user.firstName = jwtInfo.given_name; + state.user.lastName = jwtInfo.family_name; + }); + } throw routerRedirect({ to: redirect, diff --git a/frontend/src/routes/map/index.tsx b/frontend/src/routes/map/index.tsx deleted file mode 100644 index d9a80979..00000000 --- a/frontend/src/routes/map/index.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { treeApi } from "@/api/backendApi"; -import Map from "@/components/Map"; -import MapMarker, { TreeIcon } from "@/components/MapMarker"; -import MapTooltip from "@/components/MapTooltip"; -import { useTrees } from "@/context/TreeDataContext"; -import { cn } from "@/lib/utils"; -import useMapStore from "@/store/map/store"; -import { Tree } from "@green-ecolution/backend-client"; -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { Activity, Battery, Droplet, TreePine } from "lucide-react"; -import { useEffect, useMemo } from "react"; -import { useMapEvents } from "react-leaflet/hooks"; -import { toast } from "sonner"; -import { z } from "zod"; - -const mapSearchParamsSchema = z.object({ - selected: z.string().optional(), - lat: z.number().catch(useMapStore.getState().map.center[0]), - lng: z.number().catch(useMapStore.getState().map.center[1]), - zoom: z - .number() - .int() - .max(useMapStore.getState().map.maxZoom) - .min(useMapStore.getState().map.minZoom) - .catch(useMapStore.getState().map.minZoom), -}); - -export const Route = createFileRoute("/map/")({ - component: MapView, - validateSearch: mapSearchParamsSchema, - loaderDeps: ({ search: { lat, lng, zoom } }) => ({ lat, lng, zoom }), - loader: ({ deps: { lat, lng, zoom } }) => { - useMapStore.setState((state) => ({ - map: { ...state.map, center: [lat, lng], zoom }, - })); - }, -}); - -function MapView() { - const { tooltipOpen, tooltipContent, closeTooltip } = - useMapStore((state) => ({ - tooltipContent: state.tooltip.content, - tooltipOpen: state.tooltip.isOpen, - closeTooltip: state.tooltip.close, - })); - - const trees = useTrees(); - const tooltipTitle = "Baumgruppe 110"; - const tooltipDescription = "Zuletzt gegossen: vor 2 Tagen"; - - return ( -
- closeTooltip()} - title={tooltipTitle} - description={tooltipDescription} - > - - - - - - -
- ); -} - -const MapConroller = () => { - const navigate = useNavigate({ from: Route.fullPath }); - const { setCenter, setZoom } = useMapStore((state) => ({ - setCenter: state.map.setCenter, - setZoom: state.map.setZoom, - })); - const map = useMapEvents({ - moveend: () => { - const center = map.getCenter(); - const zoom = map.getZoom(); - setCenter([center.lat, center.lng]); - setZoom(zoom); - navigate({ - search: (prev) => ({ ...prev, lat: center.lat, lng: center.lng, zoom }), - }); - }, - }); - - return null; -}; - -const TreeMarker = ({ trees }: { trees: Tree[] }) => { - const { openTooltip } = useMapStore((state) => ({ - openTooltip: state.tooltip.open, - })); - const treeMarkers = useMemo( - () => - trees.map((tree) => ( - { - openTooltip(tree); - }} - /> - )), - [trees], - ); - - return <>{treeMarkers}; -}; - -const MapTooltipContent = ({ tree }: { tree: Tree }) => { - const { data, isError, error, isLoading } = useQuery({ - queryKey: ["tree_prediction", tree.id], - refetchInterval: 10000, - queryFn: () => - treeApi.getTreePredictionById({ treeID: tree.id, sensorData: true }), - }); - - useEffect(() => { - if (data === undefined && isError) { - console.error("Error while fetching trees", error); - toast.error( - "Es ist ein Fehler beim Abrufen der Sensor Daten zum Baum: " + tree.id, - { - description: error.message, - }, - ); - } - }, [isError, error]); - - const sensorData = useMemo(() => { - return ( - data?.sensorData?.map((d) => ({ - humidity: d.uplinkMessage.decodedPayload.humidity, - battery: d.uplinkMessage.decodedPayload.battery, - trunkHumidity: 113, - timestamp: new Date(d.uplinkMessage.receivedAt), - })) ?? [] - ); - }, [data]); - - const treeActionRecommendation = useMemo(() => { - if (data?.sensorPrediction.health === "good") { - return { - health: "In Ordnung", - content: - "Die Baumgruppe benötigt keine Pflege und ist in einem guten Zustand", - icon: ( -
- ), - }; - } else if (data?.sensorPrediction.health === "moderate") { - return { - health: "Mäßig", - content: "Die Baumgruppe benötigt bald Wasser und Pflege", - icon: ( -
- ), - }; - } else { - return { - health: "Schlecht", - content: "Die Baumgruppe benötigt dringend Wasser und Pflege", - icon: ( -
- ), - }; - } - }, [data?.sensorPrediction]); - - const lastSensorData = useMemo( - () => ({ - data: sensorData[sensorData.length - 1], - humidityDiff: - sensorData[sensorData.length - 1]?.humidity - - sensorData[sensorData.length - 2]?.humidity || 0, - batteryDiff: - sensorData[sensorData.length - 1]?.battery - - sensorData[sensorData.length - 2]?.battery || 0, - }), - [sensorData], - ); - - if (!data || isLoading) { - return
Loading...
; - } - - return ( -
-
-
- - Handlungsempfehlung -
-
- {treeActionRecommendation.icon} - {treeActionRecommendation.health} -
-
- -
-
- - Bodenfeuchtigkeit -
-
- {lastSensorData.data.humidity}% -
-
- -
-
- - Stammfeuchte -
-
- {lastSensorData.data.trunkHumidity} kΩ -
-
- -
-
- - Batterie -
-
- {lastSensorData.data.battery} Volt -
-
-
- ); -}; diff --git a/frontend/src/store/auth/authStore.ts b/frontend/src/store/auth/authStore.ts index bfc1721b..a621c1fb 100644 --- a/frontend/src/store/auth/authStore.ts +++ b/frontend/src/store/auth/authStore.ts @@ -1,31 +1,16 @@ -import { ClientToken } from "@green-ecolution/backend-client"; -import { create } from "zustand"; -import { devtools } from "zustand/middleware"; -import { immer } from "zustand/middleware/immer"; +import { SubStore } from "../store"; +import { AuthStore } from "./types"; -type State = { - isAuthenticated: boolean; - token: ClientToken | null; - apiHeader: string; -}; +export const authStore: SubStore = (set, get) => ({ + isAuthenticated: false, + token: null, + setIsAuthenticated: (auth) => + set((state) => { + state.auth.isAuthenticated = auth; + }), + setToken: (token) => + set((state) => { + state.auth.token = token; + }), +}); -type Actions = { - setIsAuthenticated: (auth: boolean) => void; - setAccessToken: (token: string) => void; -}; - -type Store = State & Actions; - -const useAuthStore = create()( - devtools( - immer((set) => ({ - isAuthenticated: false, - token: null, - setIsAuthenticated: (auth) => set((state) => ({ ...state, isAuthenticated: auth })), - setAccessToken: (token) => set((state) => ({ ...state, accessToken: token })), - apiHeader: "", - })) - ) -); - -export default useAuthStore; diff --git a/frontend/src/store/auth/types.ts b/frontend/src/store/auth/types.ts new file mode 100644 index 00000000..46908c85 --- /dev/null +++ b/frontend/src/store/auth/types.ts @@ -0,0 +1,14 @@ +import { ClientToken } from "@green-ecolution/backend-client"; + +type AuthState = { + isAuthenticated: boolean; + token: ClientToken | null; +}; + +type AuthActions = { + setIsAuthenticated: (auth: boolean) => void; + setToken: (token: ClientToken) => void; +}; + +export type AuthStore = AuthState & AuthActions; + diff --git a/frontend/src/store/map/mapSlice.ts b/frontend/src/store/map/mapSlice.ts deleted file mode 100644 index f7abfc22..00000000 --- a/frontend/src/store/map/mapSlice.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { MapStateCreator } from "./store"; - -export interface MapControllSlice { - center: [number, number]; - zoom: number; - minZoom: number; - maxZoom: number; - setCenter: (center: [number, number]) => void; - setZoom: (zoom: number) => void; -} - -const createMapControllSlice: MapStateCreator = (set) => ({ - center: [54.792277136221905, 9.43580607453268], - zoom: 13, - minZoom: 13, - maxZoom: 18, - setCenter: (center) => - set((state) => { - state.map.center = center; - }), - setZoom: (zoom) => - set((state) => { - const newZoom = Math.max(13, Math.min(18, zoom)); - state.map.zoom = newZoom; - }), -}); - -export default createMapControllSlice; diff --git a/frontend/src/store/map/mapStore.ts b/frontend/src/store/map/mapStore.ts new file mode 100644 index 00000000..5d4b5e32 --- /dev/null +++ b/frontend/src/store/map/mapStore.ts @@ -0,0 +1,17 @@ +import { SubStore } from "../store"; +import { MapStore } from "./types"; + +export const mapStore: SubStore = (set, get) => ({ + center: [54.792277136221905, 9.43580607453268], + zoom: 13, + minZoom: 13, + maxZoom: 18, + setCenter: (center) => + set((state) => { + state.map.center = center; + }), + setZoom: (zoom) => + set((state) => { + state.map.zoom = zoom; + }), +}); diff --git a/frontend/src/store/map/store.ts b/frontend/src/store/map/store.ts deleted file mode 100644 index 1ffe2b4d..00000000 --- a/frontend/src/store/map/store.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { create, StateCreator } from "zustand"; -import { devtools } from "zustand/middleware"; -import { immer } from 'zustand/middleware/immer' -import createTooltipSlice, { TooltipSlice } from "./tooltipSlice"; -import createMapControllSlice, { MapControllSlice } from "./mapSlice"; - -export interface Store { - map: MapControllSlice; - tooltip: TooltipSlice; -}; - -export type MapStateCreator = StateCreator; - -const useMapStore = create()( - devtools(immer((...args) => ({ - map: createMapControllSlice(...args), - tooltip: createTooltipSlice(...args), - }))), -); - -export default useMapStore; diff --git a/frontend/src/store/map/tooltipSlice.ts b/frontend/src/store/map/tooltipSlice.ts deleted file mode 100644 index c941df5e..00000000 --- a/frontend/src/store/map/tooltipSlice.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Tree } from "@green-ecolution/backend-client"; -import { MapStateCreator } from "./store"; - -export interface TooltipSlice { - isOpen: boolean; - content: Tree; - toggle: () => void; - open: (content: Tree) => void; - close: () => void; -} - -const createTooltipSlice: MapStateCreator = (set) => ({ - isOpen: false, - content: {} as Tree, - toggle: () => - set((state) => { - state.tooltip.isOpen = !state.tooltip.isOpen; - }), - open: (content) => - set((state) => { - state.tooltip.isOpen = true; - state.tooltip.content = content; - }), - close: () => - set((state) => { - state.tooltip.isOpen = false; - state.tooltip.content = {} as Tree; - }), -}); - -export default createTooltipSlice; diff --git a/frontend/src/store/map/types.ts b/frontend/src/store/map/types.ts new file mode 100644 index 00000000..4a23eea1 --- /dev/null +++ b/frontend/src/store/map/types.ts @@ -0,0 +1,14 @@ + +type MapState = { + center: [number, number]; + zoom: number; + minZoom: number; + maxZoom: number; +}; + +type MapActions = { + setCenter: (center: [number, number]) => void; + setZoom: (zoom: number) => void; +}; + +export type MapStore = MapState & MapActions; diff --git a/frontend/src/store/sidePanelStore.ts b/frontend/src/store/sidePanelStore.ts deleted file mode 100644 index dbc5a3b8..00000000 --- a/frontend/src/store/sidePanelStore.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { create } from "zustand"; - -type State = { - isOpen: boolean; -}; - -type Actions = { - toggle: () => void; - open: () => void; - close: () => void; -}; - -type Store = State & Actions; - -const useSidePanelStore = create((set) => ({ - isOpen: false, - toggle: () => set((state) => ({ isOpen: !state.isOpen })), - open: () => set({ isOpen: true }), - close: () => set({ isOpen: false }), -})); - -export const useIsSidePanelOpen = () => useSidePanelStore((state) => state.isOpen); -export const useToggleSidePanel = () => useSidePanelStore((state) => state.toggle); - -export default useSidePanelStore; diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts new file mode 100644 index 00000000..8eb74e48 --- /dev/null +++ b/frontend/src/store/store.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; +import { immer } from 'zustand/middleware/immer' +import { AuthStore } from "./auth/types"; +import { UserStore } from "./user/types"; +import { WritableDraft } from "immer"; +import { userStore } from "./user/userStore"; +import { authStore } from "./auth/authStore"; +import { MapStore } from "./map/types"; +import { mapStore } from "./map/mapStore"; + +export interface Store { + auth: AuthStore; + user: UserStore; + map: MapStore; +}; + +export type SubStore = ( + set: ( + nextStateOrUpdater: + | Store + | Partial + | ((state: WritableDraft) => void), + shouldReplace?: boolean, + ) => void, + get: () => Store, +) => T; + +const useStore = create()( + devtools(immer((set, get) => ({ + auth: authStore(set, get), + user: userStore(set, get), + map: mapStore(set, get), + }))) +); + +export const useAuthStore = () => useStore((state) => state.auth); +export const useUserStore = () => useStore((state) => state.user); +export const useMapStore = () => useStore((state) => state.map); + +export default useStore; diff --git a/frontend/src/store/user/types.ts b/frontend/src/store/user/types.ts new file mode 100644 index 00000000..037d15e6 --- /dev/null +++ b/frontend/src/store/user/types.ts @@ -0,0 +1,16 @@ +type UserState = { + username: string; + email: string; + firstName: string; + lastName: string; +}; + +type UserActions = { + setUsername: (username: string) => void; + setEmail: (email: string) => void; + setFirstName: (firstName: string) => void; + setLastName: (lastName: string) => void; +}; + +export type UserStore = UserState & UserActions; + diff --git a/frontend/src/store/user/userStore.ts b/frontend/src/store/user/userStore.ts new file mode 100644 index 00000000..8902ff30 --- /dev/null +++ b/frontend/src/store/user/userStore.ts @@ -0,0 +1,25 @@ +import { SubStore } from "../store"; +import { UserStore } from "./types"; + +export const userStore: SubStore = (set, get) => ({ + username: "", + email: "", + firstName: "", + lastName: "", + setUsername: (username) => + set((state) => { + state.user.username = username; + }), + setEmail: (email) => + set((state) => { + state.user.email = email; + }), + setFirstName: (firstName) => + set((state) => { + state.user.firstName = firstName; + }), + setLastName: (lastName) => + set((state) => { + state.user.lastName = lastName; + }), +}); From d96f6efe869db5ca00166845084205089720b3e5 Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Wed, 4 Sep 2024 21:33:28 +0200 Subject: [PATCH 06/13] chore: update backend-script --- backend-client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend-client/package.json b/backend-client/package.json index 63342e09..ce5df237 100644 --- a/backend-client/package.json +++ b/backend-client/package.json @@ -10,7 +10,7 @@ "scripts": { "generate": "./openapi-generator.sh remote https://app.dev.green-ecolution.de/api/v1", "generate:ci": "./openapi-generator.sh local", - "generate:local": "./openapi-generator.sh remote http://localhost:8080/api/v1", + "generate:local": "./openapi-generator.sh remote http://localhost:3000/api/v1", "generate:dev": "./openapi-generator.sh remote https://app.dev.green-ecolution.de/api/v1", "build": "tsc" }, From 9bda7628838195021b2d78e81eebdb11c6e6438d Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Wed, 4 Sep 2024 21:33:41 +0200 Subject: [PATCH 07/13] feat: display user name --- frontend/src/routes/_protected/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/_protected/index.tsx b/frontend/src/routes/_protected/index.tsx index 6b69a28a..01aadbc1 100644 --- a/frontend/src/routes/_protected/index.tsx +++ b/frontend/src/routes/_protected/index.tsx @@ -1,11 +1,13 @@ import DashboardCard from '@/components/general/cards/DashboardCard'; import { createFileRoute } from '@tanstack/react-router' +import useStore from '@/store/store'; export const Route = createFileRoute('/_protected/')({ component: Dashboard, }) function Dashboard() { + const user = useStore((state) => state.user); const cards = [ { @@ -50,7 +52,7 @@ function Dashboard() {

- Willkommen zurück, Vorname Nachname! + Willkommen zurück, {`${user.firstName} ${user.lastName}`}!

Labore id duis minim nisi duis incididunt. Aliqua qui dolor laborum anim aliquip sit nulla eiusmod laboris excepteur sit non laboris do. From 0b75c918be7a1f277052b06c4b0ea355af1fabb1 Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Wed, 4 Sep 2024 21:33:49 +0200 Subject: [PATCH 08/13] feat: implement logout --- frontend/src/api/backendApi.ts | 4 +- frontend/src/components/layout/Navigation.tsx | 97 +++++++++++++------ frontend/src/routeTree.gen.ts | 36 +++++++ frontend/src/routes/_protected.tsx | 4 +- frontend/src/routes/auth/callback.tsx | 7 +- frontend/src/routes/login.tsx | 13 +++ frontend/src/routes/logout.tsx | 33 +++++++ 7 files changed, 155 insertions(+), 39 deletions(-) create mode 100644 frontend/src/routes/login.tsx create mode 100644 frontend/src/routes/logout.tsx diff --git a/frontend/src/api/backendApi.ts b/frontend/src/api/backendApi.ts index fc970be6..51204608 100644 --- a/frontend/src/api/backendApi.ts +++ b/frontend/src/api/backendApi.ts @@ -3,8 +3,8 @@ import { ConfigurationParameters, HTTPHeaders, InfoApi, - LoginApi, TreesApi, + UserApi, } from "@green-ecolution/backend-client"; const headers: HTTPHeaders = { @@ -21,6 +21,6 @@ const config = new Configuration(configParams); export const treeApi = new TreesApi(config); export const infoApi = new InfoApi(config); -export const loginApi = new LoginApi(config); +export const userApi = new UserApi(config); export * from "@green-ecolution/backend-client"; diff --git a/frontend/src/components/layout/Navigation.tsx b/frontend/src/components/layout/Navigation.tsx index 2a0dcbf3..0951f7da 100644 --- a/frontend/src/components/layout/Navigation.tsx +++ b/frontend/src/components/layout/Navigation.tsx @@ -1,8 +1,21 @@ -import { ArrowLeftRight, Bug, Car, FolderClosed, HardDrive, Info, LogOut, Map, PieChart, Settings, Users } from 'lucide-react'; -import * as React from 'react'; -import NavLink from '../navigation/NavLink'; -import NavHeadline from '../navigation/NavHeadline'; -import NavHeader from '../navigation/NavHeader'; +import { + ArrowLeftRight, + Bug, + Car, + FolderClosed, + HardDrive, + LogIn, + LogOut, + Map, + PieChart, + Settings, + Users, +} from "lucide-react"; +import * as React from "react"; +import NavLink from "../navigation/NavLink"; +import NavHeadline from "../navigation/NavHeadline"; +import NavHeader from "../navigation/NavHeader"; +import useStore from "@/store/store"; interface NavigationProps { isOpen: boolean; @@ -10,8 +23,13 @@ interface NavigationProps { closeSidebar: () => void; } -const Navigation: React.FC = ({ isOpen, openSidebar, closeSidebar }) => { - const isLargeScreen = () => window.matchMedia('(min-width: 1024px)').matches; +const Navigation: React.FC = ({ + isOpen, + openSidebar, + closeSidebar, +}) => { + const isLargeScreen = () => window.matchMedia("(min-width: 1024px)").matches; + const isLoggedIn = useStore((state) => state.auth.isAuthenticated); const handleMouseOver = () => { if (isLargeScreen()) openSidebar(); @@ -25,72 +43,72 @@ const Navigation: React.FC = ({ isOpen, openSidebar, closeSideb if (!isLargeScreen()) closeSidebar(); }; - const navigationLinks = [ + const protectedNavLinks = [ { - headline: 'Grünflächen', + headline: "Grünflächen", links: [ { - label: 'Baumkataster', + label: "Baumkataster", icon: , to: "/map", }, { - label: 'Baumgruppen', + label: "Baumgruppen", icon: , to: "/treecluster", }, { - label: 'Beete', + label: "Beete", icon: , to: "/", }, ], }, { - headline: 'Einsatzplanung', + headline: "Einsatzplanung", links: [ { - label: 'Einsätze', + label: "Einsätze", icon: , to: "/waypoints", }, { - label: 'Fahrzeuge', + label: "Fahrzeuge", icon: , to: "/vehicles", }, { - label: 'Mitarbeitenden', + label: "Mitarbeitenden", icon: , to: "/team", }, ], }, { - headline: 'Weiteres', + headline: "Weiteres", links: [ { - label: 'Sensoren', + label: "Sensoren", icon: , to: "/sensors", }, { - label: 'Auswertungen', + label: "Auswertungen", icon: , to: "/evaluations", }, { - label: 'Einstellungen', + label: "Einstellungen", icon: , to: "/settings", }, { - label: 'Debug', + label: "Debug", icon: , to: "/debug", }, { - label: 'Ausloggen', + label: "Ausloggen", icon: , to: "/logout", }, @@ -98,6 +116,23 @@ const Navigation: React.FC = ({ isOpen, openSidebar, closeSideb }, ]; + // This is currently invisible to the user as the application is redirected directly to the login page. + // Maybe for future use. + const publicNavLinks = [ + { + headline: "", + links: [ + { + label: "Anmelden", + icon: , + to: "/login", + }, + ], + }, + ]; + + const navigationLinks = isLoggedIn ? protectedNavLinks : publicNavLinks; + return (

); -} +}; export default Navigation; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 7efed9e1..feaef69b 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -11,6 +11,8 @@ // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as LogoutImport } from './routes/logout' +import { Route as LoginImport } from './routes/login' import { Route as ProtectedImport } from './routes/_protected' import { Route as ProtectedIndexImport } from './routes/_protected/index' import { Route as AuthCallbackImport } from './routes/auth/callback' @@ -28,6 +30,16 @@ import { Route as ProtectedDashboardTreeTreeIdImport } from './routes/_protected // Create/Update Routes +const LogoutRoute = LogoutImport.update({ + path: '/logout', + getParentRoute: () => rootRoute, +} as any) + +const LoginRoute = LoginImport.update({ + path: '/login', + getParentRoute: () => rootRoute, +} as any) + const ProtectedRoute = ProtectedImport.update({ id: '/_protected', getParentRoute: () => rootRoute, @@ -110,6 +122,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedImport parentRoute: typeof rootRoute } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginImport + parentRoute: typeof rootRoute + } + '/logout': { + id: '/logout' + path: '/logout' + fullPath: '/logout' + preLoaderRoute: typeof LogoutImport + parentRoute: typeof rootRoute + } '/_protected/debug': { id: '/_protected/debug' path: '/debug' @@ -221,6 +247,8 @@ export const routeTree = rootRoute.addChildren({ ProtectedWaypointsIndexRoute, ProtectedDashboardTreeTreeIdRoute, }), + LoginRoute, + LogoutRoute, AuthCallbackRoute, }) @@ -233,6 +261,8 @@ export const routeTree = rootRoute.addChildren({ "filePath": "__root.tsx", "children": [ "/_protected", + "/login", + "/logout", "/auth/callback" ] }, @@ -253,6 +283,12 @@ export const routeTree = rootRoute.addChildren({ "/_protected/dashboard/tree/$treeId" ] }, + "/login": { + "filePath": "login.tsx" + }, + "/logout": { + "filePath": "logout.tsx" + }, "/_protected/debug": { "filePath": "_protected/debug.tsx", "parent": "/_protected" diff --git a/frontend/src/routes/_protected.tsx b/frontend/src/routes/_protected.tsx index 22658238..dc551a96 100644 --- a/frontend/src/routes/_protected.tsx +++ b/frontend/src/routes/_protected.tsx @@ -1,4 +1,4 @@ -import { loginApi } from '@/api/backendApi' +import { userApi } from '@/api/backendApi' import useAuthStore from '@/store/store' import { createFileRoute } from '@tanstack/react-router' @@ -7,7 +7,7 @@ export const Route = createFileRoute('/_protected')({ const isAuthenticated = useAuthStore.getState().auth.isAuthenticated const currentPath = (location.pathname+location.search) if (!isAuthenticated) { - const loginUrl = await loginApi.v1LoginGet({ + const loginUrl = await userApi.v1UserLoginGet({ redirectUrl: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(currentPath)}` }).then((res) => res.loginUrl) diff --git a/frontend/src/routes/auth/callback.tsx b/frontend/src/routes/auth/callback.tsx index b6f62afd..6f917c4d 100644 --- a/frontend/src/routes/auth/callback.tsx +++ b/frontend/src/routes/auth/callback.tsx @@ -1,4 +1,4 @@ -import { loginApi } from "@/api/backendApi"; +import { userApi } from "@/api/backendApi"; import { KeycloakJWT } from "@/lib/types/keycloak"; import { decodeJWT } from "@/lib/utils"; import useStore from "@/store/store"; @@ -19,8 +19,8 @@ export const Route = createFileRoute("/auth/callback")({ validateSearch: authSearchParamsSchema, loaderDeps: ({ search: { code } }) => ({ code }), beforeLoad: async ({ search: { code, redirect } }) => { - const token = await loginApi - .v1TokenPost({ + const token = await userApi + .v1UserTokenPost({ redirectUrl: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent(redirect)}`, body: { code, @@ -41,6 +41,7 @@ export const Route = createFileRoute("/auth/callback")({ state.auth.isAuthenticated = true; state.auth.token = token; }); + console.log(token); const jwtInfo = decodeJWT(token.accessToken); diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx new file mode 100644 index 00000000..cb7963f2 --- /dev/null +++ b/frontend/src/routes/login.tsx @@ -0,0 +1,13 @@ +import { userApi } from '@/api/backendApi' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/login')({ + beforeLoad: async () => { + const loginUrl = await userApi.v1UserLoginGet({ + redirectUrl: `${window.location.origin}/auth/callback?redirect=${encodeURIComponent("/")}` + }).then((res) => res.loginUrl) + + window.location.href = loginUrl + } + +}) diff --git a/frontend/src/routes/logout.tsx b/frontend/src/routes/logout.tsx new file mode 100644 index 00000000..ed776ece --- /dev/null +++ b/frontend/src/routes/logout.tsx @@ -0,0 +1,33 @@ +import { userApi } from "@/api/backendApi"; +import useStore from "@/store/store"; +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/logout")({ + beforeLoad: async () => { + const store = useStore.getState(); + + if (!store.auth.isAuthenticated) { + throw redirect({ to: "/login" }); + } + + await userApi + .v1UserLogoutPost({ + body: { + refreshToken: store.auth.token?.refreshToken || "", + }, + }) + .then(() => { + console.log("Logged out"); + useStore.setState((state) => { + state.auth.isAuthenticated = false; + state.auth.token = null; + }); + }) + .catch((err) => { + console.error(err); + throw new Error(err.message); + }); + + throw redirect({ to: "/" }); + }, +}); From f7ccb186df5c703a77446fe484745bb8d5ccf98e Mon Sep 17 00:00:00 2001 From: Cedrik Hoffmann Date: Wed, 4 Sep 2024 21:46:33 +0200 Subject: [PATCH 09/13] chore: remove not used api endpoints and views --- frontend/src/App.tsx | 30 +- frontend/src/api/backendApi.ts | 3 +- frontend/src/context/FakeTreeDataContext.tsx | 277 --------------- frontend/src/context/TreeDataContext.tsx | 65 ---- .../_protected/dashboard/tree/$treeId.tsx | 193 +--------- frontend/src/routes/_protected/map/index.tsx | 26 -- .../src/routes/_protected/waypoints/index.tsx | 330 +----------------- .../src/routes/_protected/waypoints/new.tsx | 259 +------------- frontend/src/routes/auth/callback.tsx | 2 - frontend/src/routes/logout.tsx | 4 +- frontend/src/vite-env.d.ts | 7 - 11 files changed, 27 insertions(+), 1169 deletions(-) delete mode 100644 frontend/src/context/FakeTreeDataContext.tsx delete mode 100644 frontend/src/context/TreeDataContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc36e2fb..d4e28bad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,9 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import queryClient from "./api/queryClient"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import React, { Suspense } from "react"; -import { TreeDataContextProvider } from "./context/TreeDataContext"; import { Toaster } from "@/components/ui/sonner"; -import { FakeTreeDataContextProvider } from "./context/FakeTreeDataContext"; import { TooltipProvider } from "./components/ui/tooltip"; import Footer from "./components/layout/Footer"; import Header from "./components/layout/Header"; @@ -25,22 +23,18 @@ function App() { - - - - - - -
-
- -
-