diff --git a/frontend/src/DataContext.tsx b/frontend/src/DataContext.tsx index 58fbfbd1..14fb1e34 100644 --- a/frontend/src/DataContext.tsx +++ b/frontend/src/DataContext.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) // SPDX-License-Identifier: Apache-2.0 -import React, {createContext, useState, useEffect, useMemo} from 'react'; +import React, {createContext, useState, useEffect, useMemo, useContext} from 'react'; import {useAppDispatch, useAppSelector} from 'store/hooks'; import { useGetScenariosQuery, @@ -43,6 +43,8 @@ import { setStartDate, } from './store/DataSelectionSlice'; import theme from './util/Theme'; +import {AuthContext} from 'react-oauth2-code-pkce'; +import {setToken} from './store/AuthSlice'; // Create the context export const DataContext = createContext<{ @@ -87,6 +89,12 @@ export const DataProvider = ({children}: {children: React.ReactNode}) => { const [geoData, setGeoData] = useState(); const [searchBarData, setSearchBarData] = useState(undefined); + const {token} = useContext(AuthContext); + + useEffect(() => { + dispatch(setToken(token)); + }, [dispatch, token]); + const selectedDistrict = useAppSelector((state) => state.dataSelection.district.ags); const selectedScenario = useAppSelector((state) => state.dataSelection.scenario); const activeScenarios = useAppSelector((state) => state.dataSelection.activeScenarios); diff --git a/frontend/src/components/AuthProvider.tsx b/frontend/src/components/AuthProvider.tsx index a5b5ea3c..ea1f3bd9 100644 --- a/frontend/src/components/AuthProvider.tsx +++ b/frontend/src/components/AuthProvider.tsx @@ -1,8 +1,7 @@ // SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) and CISPA Helmholtz Center for Information Security // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; -import {ReactNode} from 'react'; +import React, {ReactNode} from 'react'; import {AuthProvider as OAuth2WithPkceProvider, TAuthConfig} from 'react-oauth2-code-pkce'; import {useAppSelector} from 'store/hooks'; @@ -11,7 +10,7 @@ interface AuthProviderProps { } function AuthProvider({children}: AuthProviderProps) { - const realm = useAppSelector((state) => state.realm.name); + const realm = useAppSelector((state) => state.auth.realm); let authConfig: TAuthConfig; if (!import.meta.env.VITE_OAUTH_CLIENT_ID || !import.meta.env.VITE_OAUTH_API_URL) { @@ -36,7 +35,7 @@ function AuthProvider({children}: AuthProviderProps) { import.meta.env.VITE_OAUTH_REDIRECT_URL === undefined ? window.location.origin : `${import.meta.env.VITE_OAUTH_REDIRECT_URL}`, - scope: 'openid profile email', // default scope without audience + scope: 'openid profile email loki-back-audience roles', // default scope without audience autoLogin: false, }; } diff --git a/frontend/src/components/LineChartContainer.tsx b/frontend/src/components/LineChartContainer.tsx index 75280bc8..fe32c5d2 100644 --- a/frontend/src/components/LineChartContainer.tsx +++ b/frontend/src/components/LineChartContainer.tsx @@ -73,7 +73,7 @@ export default function LineChartContainer() { return lines; }); - }, [lineChartData, scenarios]); + }, [lineChartData, scenarioColors, scenarios]); // Set reference day in store useEffect(() => { diff --git a/frontend/src/components/ScenarioComponents/FilterComponents/FilterDialogContainer.tsx b/frontend/src/components/ScenarioComponents/FilterComponents/FilterDialogContainer.tsx index d8bf524a..4697d266 100644 --- a/frontend/src/components/ScenarioComponents/FilterComponents/FilterDialogContainer.tsx +++ b/frontend/src/components/ScenarioComponents/FilterComponents/FilterDialogContainer.tsx @@ -11,6 +11,7 @@ import {GroupFilter} from 'types/group'; import {Localization} from 'types/localization'; import {useAppDispatch} from '../../../store/hooks'; import {setIsFilterDialogOpen} from '../../../store/UserOnboardingSlice'; +import Button from '@mui/material/Button'; export interface FilterDialogContainerProps { /** A dictionary of group filters. */ @@ -65,45 +66,70 @@ export default function FilterDialogContainer({ }; return ( - - setGroupEditorUnsavedChanges(unsavedChanges)} - localization={localization} - /> - + + + setGroupEditorUnsavedChanges(unsavedChanges)} + localization={localization} + /> + - + text={ + localization?.overrides?.['group-filters.confirm-discard-text'] + ? customT(localization.overrides['group-filters.confirm-discard-text']) + : defaultT('group-filters.confirm-discard-text') + } + abortButtonText={ + localization?.overrides?.['group-filters.close'] + ? customT(localization.overrides['group-filters.close']) + : defaultT('group-filters.close') + } + confirmButtonText={ + localization?.overrides?.['group-filters.discard'] + ? customT(localization.overrides['group-filters.discard']) + : defaultT('group-filters.discard') + } + onAnswer={(answer) => { + if (answer) { + setOpen(false); + } + setCloseDialogOpen(false); + }} + /> + + ); } diff --git a/frontend/src/components/ScenarioComponents/ScenarioContainer.tsx b/frontend/src/components/ScenarioComponents/ScenarioContainer.tsx index 47f55f30..8b5d73f3 100644 --- a/frontend/src/components/ScenarioComponents/ScenarioContainer.tsx +++ b/frontend/src/components/ScenarioComponents/ScenarioContainer.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import {darken, useTheme} from '@mui/material/'; import React, {useContext, useEffect, useMemo, useState} from 'react'; import {NumberFormatter} from 'util/hooks'; @@ -25,6 +24,7 @@ import FilterDialogContainer from './FilterComponents/FilterDialogContainer'; import GeneralButton from './ExpandedButtonComponents/ExpandedButton'; import ReferenceDatePicker from './ReferenceDatePickerComponents.tsx/ReferenceDatePicker'; import {useAppDispatch, useAppSelector} from 'store/hooks'; +import ScenarioLibrary from './ScenarioLibrary'; interface ScenarioContainerProps { /** The minimum number of compartment rows.*/ @@ -255,30 +255,7 @@ export default function ScenarioContainer({minCompartmentsRows = 4, maxCompartme flexDirection: 'column', }} > - - - {groupCategories && groups && ( + dispatch(setGroupFilters(newGroupFilters))} localization={localization} /> - )} + diff --git a/frontend/src/components/ScenarioComponents/ScenarioLibrary.tsx b/frontend/src/components/ScenarioComponents/ScenarioLibrary.tsx new file mode 100644 index 00000000..54597915 --- /dev/null +++ b/frontend/src/components/ScenarioComponents/ScenarioLibrary.tsx @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) +// SPDX-License-Identifier: Apache-2.0 + +import React, {useContext, useMemo, useRef, useState} from 'react'; +import Paper from '@mui/material/Paper'; +import Popper from '@mui/material/Popper'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import {useTheme} from '@mui/material/styles'; +import {useTranslation} from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import Divider from '@mui/material/Divider'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import {showScenario} from '../../store/DataSelectionSlice'; +import {useAppDispatch, useAppSelector} from '../../store/hooks'; +import IconButton from '@mui/material/IconButton'; +import Close from '@mui/icons-material/Close'; +import CardTitle from './CardsComponents/MainCard/CardTitle'; +import WebAssetOff from '@mui/icons-material/WebAssetOff'; +import {DataContext} from '../../DataContext'; + +function getState( + name: string, + shownScenarios: string[] | null, + activeScenarios: string[] | null +): 'active' | 'inactive' | 'hidden' { + if (shownScenarios?.includes(name) && activeScenarios?.includes(name)) { + return 'active'; + } + + if (shownScenarios?.includes(name)) { + return 'inactive'; + } + + return 'hidden'; +} + +type ScenarioState = {id: string; name: string; state: 'active' | 'inactive' | 'hidden'}; + +function useScenarioState(): Array { + const {scenarios} = useContext(DataContext); + const shownScenarios = useAppSelector((state) => state.dataSelection.shownScenarios); + const activeScenarios = useAppSelector((state) => state.dataSelection.activeScenarios); + + return useMemo(() => { + if (!scenarios) { + return []; + } + + const result = [ + { + id: scenarios.find((scenario) => scenario.name === 'casedata')?.id ?? '', + name: 'casedata', + state: getState('casedata', shownScenarios, activeScenarios), + }, + ]; + + for (const scenario of scenarios) { + if (scenario.name === 'casedata') { + continue; + } + result.push({ + id: scenario.id, + name: scenario.name, + state: getState(scenario.name, shownScenarios, activeScenarios), + }); + } + + return result; + }, [scenarios, shownScenarios, activeScenarios]); +} + +function useHiddenScenarios() { + const scenarios = useScenarioState(); + + return useMemo(() => scenarios.filter((scenario) => scenario.state === 'hidden'), [scenarios]); +} + +export default function ScenarioLibrary(): JSX.Element { + const {t} = useTranslation(); + const theme = useTheme(); + + const libScenarios = useHiddenScenarios(); + const anchorRef = useRef(null); + + const [open, setOpen] = useState(false); + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const id = open ? 'simple-popper' : undefined; + const handleClose = (event: Event | React.SyntheticEvent) => { + if (anchorRef.current?.contains(event.target as HTMLElement)) { + return; + } + + setOpen(false); + }; + + return ( + + + + + + + +
+ {t('scenario-library.title')} + setOpen(false)}> + + + + + + {libScenarios.length > 0 ? ( + libScenarios.map((scenario) => ) + ) : ( + + + {t('scenario-library.no-scenarios')} + + )} + + + + + + + ); +} + +function LibraryCard(props: Readonly): JSX.Element { + const dispatch = useAppDispatch(); + const theme = useTheme(); + const {t: tBackend} = useTranslation('backend'); + + return ( + + + dispatch(showScenario(props.name))} + > + + + + + + + + + + + ); +} diff --git a/frontend/src/components/TopBar/ApplicationMenu.tsx b/frontend/src/components/TopBar/ApplicationMenu.tsx index c3c20d21..0148d1d6 100644 --- a/frontend/src/components/TopBar/ApplicationMenu.tsx +++ b/frontend/src/components/TopBar/ApplicationMenu.tsx @@ -26,8 +26,8 @@ const AttributionDialog = React.lazy(() => import('./PopUps/AttributionDialog')) export default function ApplicationMenu(): JSX.Element { const {t} = useTranslation(); - const realm = useAppSelector((state) => state.realm.name); - const {login, token, logOut} = useContext(AuthContext); + const realm = useAppSelector((state) => state.auth.realm); + const {login, token, logOut, idToken} = useContext(AuthContext); console.log(token); @@ -43,6 +43,12 @@ export default function ApplicationMenu(): JSX.Element { const [attributionsOpen, setAttributionsOpen] = React.useState(false); const [changelogOpen, setChangelogOpen] = React.useState(false); + const keycloakLogout = () => { + window.location.assign( + `${import.meta.env.VITE_OAUTH_API_URL}/realms/${realm}/protocol/openid-connect/logout?post_logout_redirect_uri=${encodeURI(`${import.meta.env.VITE_OAUTH_REDIRECT_URL}`)}&id_token_hint=${idToken}` + ); + }; + /** Calling this method opens the application menu. */ const openMenu = (event: MouseEvent) => { setAnchorElement(event.currentTarget); @@ -63,6 +69,7 @@ export default function ApplicationMenu(): JSX.Element { const logoutClicked = () => { closeMenu(); logOut(); + keycloakLogout(); }; /** This method gets called, when the imprint menu entry was clicked. It opens a dialog showing the legal text. */ diff --git a/frontend/src/components/TopBar/RealmSelect.tsx b/frontend/src/components/TopBar/RealmSelect.tsx index 7a91585f..5d1764df 100644 --- a/frontend/src/components/TopBar/RealmSelect.tsx +++ b/frontend/src/components/TopBar/RealmSelect.tsx @@ -7,14 +7,14 @@ import InputLabel from '@mui/material/InputLabel'; import Select from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import {useAppDispatch, useAppSelector} from 'store/hooks'; -import {setRealm} from 'store/RealmSlice'; +import {setRealm} from 'store/AuthSlice'; import {useTranslation} from 'react-i18next'; import Box from '@mui/material/Box'; function RealmSelect() { const {t} = useTranslation(); - const realm = useAppSelector((state) => state.realm.name); + const realm = useAppSelector((state) => state.auth.realm); const dispatch = useAppDispatch(); // realms are hardcoded for now diff --git a/frontend/src/store/AuthSlice.ts b/frontend/src/store/AuthSlice.ts new file mode 100644 index 00000000..1b37fb3a --- /dev/null +++ b/frontend/src/store/AuthSlice.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) and CISPA Helmholtz Center for Information Security +// SPDX-License-Identifier: Apache-2.0 + +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +export interface Auth { + realm: string | null; + token: string | null; +} + +const initialState: Auth = { + realm: '', + token: null, +}; + +export const AuthSlice = createSlice({ + name: 'Realm', + initialState, + reducers: { + setRealm(state, action: PayloadAction) { + state.realm = action.payload; + }, + setToken(state, action: PayloadAction) { + state.token = action.payload; + }, + }, +}); + +export const {setRealm, setToken} = AuthSlice.actions; +export default AuthSlice.reducer; diff --git a/frontend/src/store/DataSelectionSlice.ts b/frontend/src/store/DataSelectionSlice.ts index c0ab45ae..7549fb1d 100644 --- a/frontend/src/store/DataSelectionSlice.ts +++ b/frontend/src/store/DataSelectionSlice.ts @@ -29,6 +29,7 @@ export interface DataSelection { scenario: string | null; compartment: string | null; compartmentsExpanded: boolean | null; + shownScenarios: string[] | null; activeScenarios: string[] | null; scenarioColors: Record> | null; simulationStart: string | null; @@ -43,6 +44,7 @@ const initialState: DataSelection = { scenario: null, compartment: null, compartmentsExpanded: null, + shownScenarios: [], activeScenarios: [], scenarioColors: null, simulationStart: null, @@ -142,6 +144,26 @@ export const DataSelectionSlice = createSlice({ state.groupFilters[action.payload].isVisible = !state.groupFilters[action.payload].isVisible; } }, + showScenario(state, action: PayloadAction) { + if (!state.shownScenarios) { + state.shownScenarios = ['casedata', 'baseline']; + } + + const index = state.shownScenarios.indexOf(action.payload); + if (index === -1) { + state.shownScenarios.push(action.payload); + } + }, + hideScenario(state, action: PayloadAction) { + if (!state.shownScenarios) { + state.shownScenarios = ['casedata', 'baseline']; + } + + const index = state.shownScenarios.indexOf(action.payload); + if (index !== -1) { + state.shownScenarios.splice(index, 1); + } + }, }, }); @@ -161,6 +183,8 @@ export const { setGroupFilter, deleteGroupFilter, toggleGroupFilter, + showScenario, + hideScenario, } = DataSelectionSlice.actions; export default DataSelectionSlice.reducer; diff --git a/frontend/src/store/RealmSlice.ts b/frontend/src/store/RealmSlice.ts deleted file mode 100644 index 6a52852c..00000000 --- a/frontend/src/store/RealmSlice.ts +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR) and CISPA Helmholtz Center for Information Security -// SPDX-License-Identifier: Apache-2.0 - -import {createSlice, PayloadAction} from '@reduxjs/toolkit'; - -export interface Realm { - name: string; -} - -const initialState: Realm = { - name: '', -}; - -export const RealmSlice = createSlice({ - name: 'Realm', - initialState, - reducers: { - setRealm(state, action: PayloadAction) { - state.name = action.payload; - }, - }, -}); - -export const {setRealm} = RealmSlice.actions; -export default RealmSlice.reducer; diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 11247e06..68d3df03 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -10,13 +10,13 @@ import {persistReducer, persistStore} from 'redux-persist'; import storage from 'redux-persist/lib/storage'; import {groupApi} from './services/groupApi'; import LayoutReducer from './LayoutSlice'; -import RealmReducer from './RealmSlice'; +import AuthReducer from './AuthSlice'; import UserOnboardingReducer from './UserOnboardingSlice'; const persistConfig = { key: 'root', storage, - whitelist: ['dataSelection', 'userPreference', 'userOnboarding', 'realm'], + whitelist: ['dataSelection', 'userPreference', 'userOnboarding', 'auth'], }; const rootReducer = combineReducers({ @@ -24,7 +24,7 @@ const rootReducer = combineReducers({ userPreference: UserPreferenceReducer, layoutSlice: LayoutReducer, userOnboarding: UserOnboardingReducer, - realm: RealmReducer, + auth: AuthReducer, [caseDataApi.reducerPath]: caseDataApi.reducer, [scenarioApi.reducerPath]: scenarioApi.reducer, [groupApi.reducerPath]: groupApi.reducer, diff --git a/frontend/src/store/services/scenarioApi.ts b/frontend/src/store/services/scenarioApi.ts index 6d30e614..65e922cb 100644 --- a/frontend/src/store/services/scenarioApi.ts +++ b/frontend/src/store/services/scenarioApi.ts @@ -25,13 +25,23 @@ import { Scenario, GroupCategories, } from './APITypes'; +import {RootState} from '../index'; export const scenarioApi = createApi({ reducerPath: 'scenarioApi', baseQuery: fetchBaseQuery({ baseUrl: `${import.meta.env.VITE_API_URL || ''}`, - prepareHeaders: (headers) => { - headers.set('Authorization', `Bearer TODO`); + prepareHeaders: (headers, {getState}) => { + const auth = (getState() as RootState).auth; + + if (auth.realm && auth.realm !== '') { + headers.set('x-realm', auth.realm); + } + + if (auth.token && auth.token !== '') { + headers.set('Authorization', 'Bearer ' + auth.token); + } + return headers; }, }),