From fbe4f815eca77a6568cbb36633a39a68d5b446fe Mon Sep 17 00:00:00 2001 From: Ian Park Date: Sat, 16 Dec 2023 02:12:28 +0900 Subject: [PATCH] Task/WG-10: Add projects and user queries. (#137) * Add login/logout and protected routes * Refactor * Fix tests * Fix linting * Fix linting * Add projects and user queries. * Fix bug. * Fix test. * Add prettier. * Update authUtils.ts * Update react/src/redux/api/geoapi.ts * Add link to related jira issue * Fix prettier issue --------- Co-authored-by: Nathan Franklin --- react/package.json | 4 +- react/src/components/AppRouter.tsx | 2 +- .../components/Authentication/Login/Login.tsx | 2 +- .../src/components/MainMenu/MainMenu.test.tsx | 8 +++- react/src/components/MainMenu/MainMenu.tsx | 6 +++ react/src/redux/api/geoapi.ts | 29 ++++++++--- react/src/redux/authSlice.ts | 48 +++++++++++++------ react/src/redux/projectsSlice.ts | 18 +++++++ react/src/redux/reducers/reducers.ts | 2 + react/src/redux/store.ts | 7 +++ react/src/types/auth.ts | 14 ++++++ react/src/types/index.ts | 1 + react/src/utils/authUtils.ts | 26 +++++----- 13 files changed, 130 insertions(+), 37 deletions(-) create mode 100644 react/src/redux/projectsSlice.ts create mode 100644 react/src/types/auth.ts diff --git a/react/package.json b/react/package.json index 2ba20486..80095147 100644 --- a/react/package.json +++ b/react/package.json @@ -10,7 +10,9 @@ "test": "jest", "lint": "npm run lint:js", "lint:js": "eslint . --ext .js,.jsx,.ts,.tsx", - "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix" + "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", + "prettier:check": "prettier --single-quote --check src", + "prettier:fix": "prettier --single-quote --write src" }, "eslintConfig": { "extends": "react-app" diff --git a/react/src/components/AppRouter.tsx b/react/src/components/AppRouter.tsx index 4ec308c2..54bfb228 100644 --- a/react/src/components/AppRouter.tsx +++ b/react/src/components/AppRouter.tsx @@ -38,7 +38,7 @@ const ProtectedRoute: React.FC = ({ function AppRouter() { const isAuthenticated = useSelector((state: RootState) => - isTokenValid(state.auth) + isTokenValid(state.auth.token) ); return ( diff --git a/react/src/components/Authentication/Login/Login.tsx b/react/src/components/Authentication/Login/Login.tsx index 091464e7..b4b2170a 100644 --- a/react/src/components/Authentication/Login/Login.tsx +++ b/react/src/components/Authentication/Login/Login.tsx @@ -8,7 +8,7 @@ function Login() { const location = useLocation(); const navigate = useNavigate(); const isAuthenticated = useSelector((state: RootState) => - isTokenValid(state.auth) + isTokenValid(state.auth.token) ); useEffect(() => { diff --git a/react/src/components/MainMenu/MainMenu.test.tsx b/react/src/components/MainMenu/MainMenu.test.tsx index f24b080d..cf38b508 100644 --- a/react/src/components/MainMenu/MainMenu.test.tsx +++ b/react/src/components/MainMenu/MainMenu.test.tsx @@ -1,8 +1,14 @@ import React from 'react'; import { render } from '@testing-library/react'; import MainMenu from './MainMenu'; +import { Provider } from 'react-redux'; +import store from '../../redux/store'; test('renders menu', () => { - const { getByText } = render(); + const { getByText } = render( + + + + ); expect(getByText(/Main Menu/)).toBeDefined(); }); diff --git a/react/src/components/MainMenu/MainMenu.tsx b/react/src/components/MainMenu/MainMenu.tsx index dcaab318..f1216a57 100644 --- a/react/src/components/MainMenu/MainMenu.tsx +++ b/react/src/components/MainMenu/MainMenu.tsx @@ -1,6 +1,12 @@ import React from 'react'; +import { + useGetGeoapiProjectsQuery, + useGetGeoapiUserInfoQuery, +} from '../../redux/api/geoapi'; function MainMenu() { + useGetGeoapiProjectsQuery(); + useGetGeoapiUserInfoQuery(); return

Main Menu

; } diff --git a/react/src/redux/api/geoapi.ts b/react/src/redux/api/geoapi.ts index 24b207eb..fa3fa531 100644 --- a/react/src/redux/api/geoapi.ts +++ b/react/src/redux/api/geoapi.ts @@ -1,21 +1,23 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/dist/query/react'; -import store from '../store'; +import type { RootState } from '../store'; // TODO: make configurable so can be https://agave.designsafe-ci.org/geo-staging/v2 or https://agave.designsafe-ci.org/geo/v2 -const BASE_URL = 'https:localhost:8888'; +// See https://tacc-main.atlassian.net/browse/WG-196 +const BASE_URL = 'https://agave.designsafe-ci.org/geo/v2'; export const geoapi = createApi({ reducerPath: 'api', baseQuery: fetchBaseQuery({ baseUrl: BASE_URL, - prepareHeaders: (headers) => { + prepareHeaders: (headers, api) => { // TODO check if logged in as we don't want to add if public - const token = store.getState().auth.token; + const token = (api.getState() as RootState).auth.token; + if (token) { - headers.set('Authorization', `Bearer ${token}`); + headers.set('Authorization', `Bearer ${token.token}`); } + headers.set('Content-Type', 'application/json;charset=UTF-8'); - headers.set('Authorization', 'anonymous'); // TODO below adding of JWT if localhost and then add JWT // we put the JWT on the request to our geoapi API because it is not behind ws02 if in local dev @@ -25,5 +27,18 @@ export const geoapi = createApi({ }, }), tagTypes: ['Test'], - endpoints: () => ({}), + endpoints: (builder) => ({ + getGeoapiProjects: builder.query({ + query: () => '/projects/', + }), + // NOTE: Currently fails due to cors on localhost (chrome) works when requesting production backend + getGeoapiUserInfo: builder.query({ + query: () => ({ + url: 'https://agave.designsafe-ci.org/oauth2/userinfo?schema=openid', + method: 'GET', + }), + }), + }), }); + +export const { useGetGeoapiProjectsQuery, useGetGeoapiUserInfoQuery } = geoapi; diff --git a/react/src/redux/authSlice.ts b/react/src/redux/authSlice.ts index 0e0c299b..532d3a5e 100644 --- a/react/src/redux/authSlice.ts +++ b/react/src/redux/authSlice.ts @@ -1,18 +1,19 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { - getAuthFromLocalStorage, - setAuthToLocalStorage, - removeAuthFromLocalStorage, + getTokenFromLocalStorage, + setTokenToLocalStorage, + removeTokenFromLocalStorage, } from '../utils/authUtils'; +import { AuthState, AuthenticatedUser } from '../types'; +import { geoapi } from './api/geoapi'; // TODO consider moving to ../types/ -export interface AuthState { - token: string | null; - expires: number | null; -} - // check local storage for our initial state -const initialState: AuthState = getAuthFromLocalStorage(); +const initialState: AuthState = { + token: getTokenFromLocalStorage(), + user: null, +}; + const authSlice = createSlice({ name: 'auth', initialState, @@ -21,19 +22,36 @@ const authSlice = createSlice({ state, action: PayloadAction<{ token: string; expires: number }> ) { - state.token = action.payload.token; - state.expires = action.payload.expires; + state.token = { + token: action.payload.token, + expires: action.payload.expires, + }; // save to local storage - setAuthToLocalStorage(state); + setTokenToLocalStorage(state.token); }, logout(state) { + state.user = null; state.token = null; - state.expires = null; - //remove from local storage - removeAuthFromLocalStorage(); + removeTokenFromLocalStorage(); }, + + setUser(state, action: PayloadAction<{ user: AuthenticatedUser }>) { + state.user = action.payload.user; + }, + }, + extraReducers: (builder) => { + builder.addMatcher( + geoapi.endpoints.getGeoapiUserInfo.matchFulfilled, + (state, action: PayloadAction) => { + const u: any = { + name: action.payload.name, + email: action.payload.email, + }; + state.user = u; + } + ); }, }); diff --git a/react/src/redux/projectsSlice.ts b/react/src/redux/projectsSlice.ts new file mode 100644 index 00000000..49d34e2e --- /dev/null +++ b/react/src/redux/projectsSlice.ts @@ -0,0 +1,18 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { geoapi } from './api/geoapi'; + +const slice = createSlice({ + name: 'projects', + initialState: { projects: [] }, + reducers: {}, + extraReducers: (builder) => { + builder.addMatcher( + geoapi.endpoints.getGeoapiProjects.matchFulfilled, + (state, { payload }) => { + state.projects = payload; + } + ); + }, +}); + +export default slice.reducer; diff --git a/react/src/redux/reducers/reducers.ts b/react/src/redux/reducers/reducers.ts index a76a42c5..d05723db 100644 --- a/react/src/redux/reducers/reducers.ts +++ b/react/src/redux/reducers/reducers.ts @@ -1,8 +1,10 @@ import { combineReducers } from 'redux'; import { geoapi } from '../api/geoapi'; import authReducer from '../authSlice'; +import projectsReducer from '../projectsSlice'; export const reducer = combineReducers({ auth: authReducer, + projects: projectsReducer, [geoapi.reducerPath]: geoapi.reducer, }); diff --git a/react/src/redux/store.ts b/react/src/redux/store.ts index b8649ed1..84e14e3c 100644 --- a/react/src/redux/store.ts +++ b/react/src/redux/store.ts @@ -1,9 +1,16 @@ import { configureStore } from '@reduxjs/toolkit'; import { reducer } from './reducers/reducers'; +import { geoapi } from './api/geoapi'; const store = configureStore({ reducer: reducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ serializableCheck: false }).concat( + geoapi.middleware + ), }); export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; + export default store; diff --git a/react/src/types/auth.ts b/react/src/types/auth.ts new file mode 100644 index 00000000..c7c524d3 --- /dev/null +++ b/react/src/types/auth.ts @@ -0,0 +1,14 @@ +export interface AuthenticatedUser { + username: string | null; + email: string | null; +} + +export interface AuthToken { + token: string | null; + expires: number | null; +} + +export interface AuthState { + user: AuthenticatedUser | null; + token: AuthToken | null; +} diff --git a/react/src/types/index.ts b/react/src/types/index.ts index 52bbcd7b..789ea821 100644 --- a/react/src/types/index.ts +++ b/react/src/types/index.ts @@ -5,3 +5,4 @@ export type { FeatureClass, FeatureCollection, } from './feature'; +export type { AuthState, AuthenticatedUser, AuthToken } from './auth'; diff --git a/react/src/utils/authUtils.ts b/react/src/utils/authUtils.ts index b804dcfd..3dd533dc 100644 --- a/react/src/utils/authUtils.ts +++ b/react/src/utils/authUtils.ts @@ -1,22 +1,26 @@ -import { AuthState } from '../redux/authSlice'; +import { AuthToken } from '../types'; export const AUTH_KEY = 'auth'; -export function isTokenValid(auth: AuthState): boolean { - if (!auth.expires) { +export function isTokenValid(token: AuthToken | null): boolean { + if (token) { + if (!token.expires) { + return false; + } + + const now = Date.now(); + return now < token.expires; + } else { return false; } - - const now = Date.now(); - return now < auth.expires; } -export function getAuthFromLocalStorage(): AuthState { +export function getTokenFromLocalStorage(): AuthToken { try { const tokenStr = localStorage.getItem(AUTH_KEY); if (tokenStr) { const auth = JSON.parse(tokenStr); - return { token: auth.token, expires: auth.expires }; + return auth; } } catch (e: any) { console.error('Error loading state from localStorage:', e); @@ -24,10 +28,10 @@ export function getAuthFromLocalStorage(): AuthState { return { token: null, expires: null }; } -export function setAuthToLocalStorage(auth: AuthState) { - localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); +export function setTokenToLocalStorage(token: AuthToken) { + localStorage.setItem(AUTH_KEY, JSON.stringify(token)); } -export function removeAuthFromLocalStorage() { +export function removeTokenFromLocalStorage() { localStorage.removeItem(AUTH_KEY); }