diff --git a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.test.tsx b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b59aaa789cd62276e130533d5d3dd710747a364 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { act } from 'react-dom/test-utils'; +import { LoginModal } from './LoginModal.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <LoginModal /> + </Wrapper>, + ), + { + store, + } + ); +}; + +test('renders LoginModal component', () => { + renderComponent(); + + const loginInput = screen.getByLabelText(/login/i); + const passwordInput = screen.getByLabelText(/password/i); + expect(loginInput).toBeInTheDocument(); + expect(passwordInput).toBeInTheDocument(); +}); + +test('handles input change correctly', () => { + renderComponent(); + + const loginInput: HTMLInputElement = screen.getByLabelText(/login/i); + const passwordInput: HTMLInputElement = screen.getByLabelText(/password/i); + + fireEvent.change(loginInput, { target: { value: 'testuser' } }); + fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); + + expect(loginInput.value).toBe('testuser'); + expect(passwordInput.value).toBe('testpassword'); +}); + +test('submits form', () => { + renderComponent(); + + const loginInput = screen.getByLabelText(/login/i); + const passwordInput = screen.getByLabelText(/password/i); + const submitButton = screen.getByText(/submit/i); + + fireEvent.change(loginInput, { target: { value: 'testuser' } }); + fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); + act(() => { + submitButton.click(); + }); + + expect(submitButton).toBeDisabled(); +}); diff --git a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..354b53377e7fa13900bc8f38b872099caf622514 --- /dev/null +++ b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx @@ -0,0 +1,67 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { loadingUserSelector } from '@/redux/user/user.selectors'; +import { login } from '@/redux/user/user.thunks'; +import { Button } from '@/shared/Button'; +import Link from 'next/link'; +import React from 'react'; + +export const LoginModal: React.FC = () => { + const dispatch = useAppDispatch(); + const loadingUser = useAppSelector(loadingUserSelector); + const isPending = loadingUser === 'pending'; + const [credentials, setCredentials] = React.useState({ login: '', password: '' }); + + const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + const { name, value } = e.target; + setCredentials(prevCredentials => ({ ...prevCredentials, [name]: value })); + }; + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => { + e.preventDefault(); + dispatch(login(credentials)); + }; + + return ( + <div className="w-[400px] border border-t-[#E1E0E6] bg-white p-[24px]"> + <form onSubmit={handleSubmit}> + <label className="mb-5 block text-sm font-semibold" htmlFor="login"> + Login: + <input + type="text" + name="login" + id="login" + placeholder="Your login here.." + value={credentials.login} + onChange={handleChange} + className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + /> + </label> + <label className="text-sm font-semibold" htmlFor="password"> + Password: + <input + type="password" + name="password" + id="password" + placeholder="Your password here.." + value={credentials.password} + onChange={handleChange} + className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + /> + </label> + <div className="mb-10 text-right"> + <Link href="/" className="ml-auto text-xs"> + Forgot password? + </Link> + </div> + <Button + type="submit" + className="w-full justify-center text-base font-medium" + disabled={isPending} + > + Submit + </Button> + </form> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/LoginModal/index.ts b/src/components/FunctionalArea/Modal/LoginModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e02c535c011f5ade65d75d26c0f690b31b32ed0d --- /dev/null +++ b/src/components/FunctionalArea/Modal/LoginModal/index.ts @@ -0,0 +1 @@ +export { LoginModal } from './LoginModal.component'; diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 4ca533eda91cab87734412be669566e14aed77ae..7367a76074a29e219cdfa2822b6899ee9b9a1af2 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -6,6 +6,7 @@ import { Icon } from '@/shared/Icon'; import { twMerge } from 'tailwind-merge'; import { MODAL_ROLE } from './Modal.constants'; import { OverviewImagesModal } from './OverviewImagesModal'; +import { LoginModal } from './LoginModal'; export const Modal = (): React.ReactNode => { const dispatch = useAppDispatch(); @@ -24,7 +25,12 @@ export const Modal = (): React.ReactNode => { role={MODAL_ROLE} > <div className="flex h-full w-full items-center justify-center"> - <div className="flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg"> + <div + className={twMerge( + 'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg', + modalName === 'login' && 'h-auto w-[400px]', + )} + > <div className="flex items-center justify-between bg-white p-[24px] text-xl"> <div>{modalTitle}</div> <button type="button" onClick={handleCloseModal} aria-label="close button"> @@ -32,6 +38,7 @@ export const Modal = (): React.ReactNode => { </button> </div> {isOpen && modalName === 'overview-images' && <OverviewImagesModal />} + {isOpen && modalName === 'login' && <LoginModal />} </div> </div> </div> diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx index dacfc35bd0ccdeab6a213e68aa4996a7f4777274..c64ecf7408b301eb3f5b77ed21ee963d748d5fb3 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx @@ -1,11 +1,15 @@ import { DrawerHeading } from '@/shared/DrawerHeading'; import { GeneralOverlays } from './GeneralOverlays'; +import { UserOverlays } from './UserOverlays'; export const OverlaysDrawer = (): JSX.Element => { return ( - <div data-testid="overlays-drawer"> + <div data-testid="overlays-drawer" className="h-full max-h-full"> <DrawerHeading title="Overlays" /> - <GeneralOverlays /> + <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto"> + <GeneralOverlays /> + <UserOverlays /> + </div> </div> ); }; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d594f01698bd6bc04fc19d67b02dc196a7e22221 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx @@ -0,0 +1,34 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { openLoginModal } from '@/redux/modal/modal.slice'; +import { authenticatedUserSelector, loadingUserSelector } from '@/redux/user/user.selectors'; +import { Button } from '@/shared/Button'; + +export const UserOverlays = (): JSX.Element => { + const dispatch = useAppDispatch(); + const loadingUser = useAppSelector(loadingUserSelector); + const authenticatedUser = useAppSelector(authenticatedUserSelector); + + const handleLoginClick = (): void => { + dispatch(openLoginModal()); + }; + + return ( + <div className="p-6"> + {loadingUser === 'pending' && <h1>Loading</h1>} + + {loadingUser !== 'pending' && !authenticatedUser && ( + <> + <p className="mb-5 font-semibold">User provided overlays:</p> + <p className="mb-5 text-sm"> + You are not logged in, please login to upload and view custom overlays + </p> + <Button onClick={handleLoginClick}>Login</Button> + </> + )} + + {/* TODO: Implement user overlays */} + {authenticatedUser && <h1>Authenticated</h1>} + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..56f7d13975e3f59a338652a5f05d35ac0c3bb0d8 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/index.ts @@ -0,0 +1 @@ +export { UserOverlays } from './UserOverlays.component'; diff --git a/src/models/fixtures/loginFixture.ts b/src/models/fixtures/loginFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0e040a9cde018ef065c65f2515c0137ccf7db55 --- /dev/null +++ b/src/models/fixtures/loginFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +import { loginSchema } from '@/models/loginSchema'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; + +export const loginFixture = createFixture(loginSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/fixtures/sessionFixture.ts b/src/models/fixtures/sessionFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa3b576f0b4f2c9c5c3c805ca4162fbfbebbf0ff --- /dev/null +++ b/src/models/fixtures/sessionFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { sessionSchemaValid } from '../sessionValidSchema'; + +export const sessionFixture = createFixture(sessionSchemaValid, { + seed: ZOD_SEED, +}); diff --git a/src/models/loginSchema.ts b/src/models/loginSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..73eb88b621d61c29acf02001b09b96b13361590d --- /dev/null +++ b/src/models/loginSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + info: z.string(), + login: z.string(), + token: z.string(), +}); diff --git a/src/models/sessionValidSchema.ts b/src/models/sessionValidSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..371cd7cd86d9fefe455aab774ae1541c318dc27c --- /dev/null +++ b/src/models/sessionValidSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const sessionSchemaValid = z.object({ + login: z.string(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index d7693545008351a6ac605b75165fb5d87e0da706..cc657a713650a32153b3ae4f994a707da2b747b3 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -29,6 +29,8 @@ export const apiPath = { getAllBackgroundsByProjectIdQuery: (projectId: string): string => `projects/${projectId}/backgrounds/`, getProjectById: (projectId: string): string => `projects/${projectId}`, + getSessionValid: (): string => `users/isSessionValid`, + postLogin: (): string => `doLogin`, getConfigurationOptions: (): string => 'configuration/options/', getOverlayBioEntity: ({ overlayId, modelId }: { overlayId: number; modelId: number }): string => `projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/`, diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index 90759575d6c3ec73246a2d402d7ed5489875f3b7..f8a1f4a00c539622d7dc49842546f67963bf5d8c 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -24,6 +24,12 @@ export const openOverviewImagesModalByIdReducer = ( }; }; +export const openLoginModalReducer = (state: ModalState): void => { + state.isOpen = true; + state.modalName = 'login'; + state.modalTitle = 'You need to login'; +}; + export const setOverviewImageIdReducer = ( state: ModalState, action: PayloadAction<number>, diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index 3dbe1970fb135799870640bf00c38f3ab5c9a080..0096d4776b06ab649aa3913674a959309c74c03d 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -2,6 +2,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { MODAL_INITIAL_STATE } from './modal.constants'; import { closeModalReducer, + openLoginModalReducer, openModalReducer, openOverviewImagesModalByIdReducer, setOverviewImageIdReducer, @@ -15,10 +16,16 @@ const modalSlice = createSlice({ closeModal: closeModalReducer, openOverviewImagesModalById: openOverviewImagesModalByIdReducer, setOverviewImageId: setOverviewImageIdReducer, + openLoginModal: openLoginModalReducer, }, }); -export const { openModal, closeModal, openOverviewImagesModalById, setOverviewImageId } = - modalSlice.actions; +export const { + openModal, + closeModal, + openOverviewImagesModalById, + setOverviewImageId, + openLoginModal, +} = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index 6ac3a819aa3b96656bbb5c0137d983837419bf59..4591391b0de75ecfe2cfa99bc66b98deec784987 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -16,6 +16,7 @@ import { } from '../map/map.thunks'; import { getSearchData } from '../search/search.thunks'; import { setPerfectMatch } from '../search/search.slice'; +import { getSessionValid } from '../user/user.thunks'; import { getConfigurationOptions } from '../configuration/configuration.thunks'; interface InitializeAppParams { @@ -44,6 +45,9 @@ export const fetchInitialAppData = createAsyncThunk< /** Create tabs for maps / submaps */ dispatch(initOpenedMaps({ queryData })); + // Check if auth token is valid + dispatch(getSessionValid()); + /** Trigger search */ if (queryData.searchValue) { dispatch(setPerfectMatch(queryData.perfectMatch)); diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 91f11a10479dab8eae2a2394e6d31c8a32b8730c..b4a62ed48d62f6479660876206a9c51c77b97578 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -13,6 +13,7 @@ import { PROJECT_STATE_INITIAL_MOCK } from '../project/project.mock'; import { REACTIONS_STATE_INITIAL_MOCK } from '../reactions/reactions.mock'; import { SEARCH_STATE_INITIAL_MOCK } from '../search/search.mock'; import { RootState } from '../store'; +import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, @@ -29,4 +30,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { configuration: CONFIGURATION_INITIAL_STATE, overlayBioEntity: OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, modal: MODAL_INITIAL_STATE_MOCK, + user: USER_INITIAL_STATE_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index 241b9e976944cd4237a36d487e5df8ee13f81225..b20bb765db9076ea021ba29bb7e51d0c0f2b1e03 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -10,6 +10,7 @@ import overlaysReducer from '@/redux/overlays/overlays.slice'; import projectReducer from '@/redux/project/project.slice'; import reactionsReducer from '@/redux/reactions/reactions.slice'; import searchReducer from '@/redux/search/search.slice'; +import userReducer from '@/redux/user/user.slice'; import configurationReducer from '@/redux/configuration/configuration.slice'; import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import { @@ -34,6 +35,7 @@ export const reducers = { overlays: overlaysReducer, models: modelsReducer, reactions: reactionsReducer, + user: userReducer, configuration: configurationReducer, overlayBioEntity: overlayBioEntityReducer, }; diff --git a/src/redux/user/user.mock.ts b/src/redux/user/user.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..1306a1b789a8e2a8bb8053774a46be3760f74202 --- /dev/null +++ b/src/redux/user/user.mock.ts @@ -0,0 +1,8 @@ +import { UserState } from './user.types'; + +export const USER_INITIAL_STATE_MOCK: UserState = { + loading: 'idle', + authenticated: false, + error: { name: '', message: '' }, + login: null, +}; diff --git a/src/redux/user/user.reducers.test.ts b/src/redux/user/user.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c89c682c219ec1c7013d3079253e94bf704d5c0 --- /dev/null +++ b/src/redux/user/user.reducers.test.ts @@ -0,0 +1,74 @@ +import { login, getSessionValid } from '@/redux/user/user.thunks'; +import type { UserState } from '@/redux/user/user.types'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { loginFixture } from '@/models/fixtures/loginFixture'; +import { sessionFixture } from '@/models/fixtures/sessionFixture'; +import { apiPath } from '../apiPath'; +import userReducer from './user.slice'; + +const mockedAxiosClient = mockNetworkResponse(); + +const CREDENTIALS = { + login: 'test', + password: 'password', +}; + +const INITIAL_STATE: UserState = { + loading: 'idle', + authenticated: false, + error: { name: '', message: '' }, + login: null, +}; + +describe('user reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<UserState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('user', userReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + expect(userReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after successful login query', async () => { + mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); + await store.dispatch(login(CREDENTIALS)); + const { authenticated, loading } = store.getState().user; + + expect(authenticated).toBe(true); + expect(loading).toEqual('succeeded'); + }); + + it('should update store on loading login query', async () => { + mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); + const loginPromise = store.dispatch(login(CREDENTIALS)); + + const { authenticated, loading } = store.getState().user; + expect(authenticated).toBe(false); + expect(loading).toEqual('pending'); + + await loginPromise; + + const { authenticated: authenticatedFulfilled, loading: promiseFulfilled } = + store.getState().user; + + expect(authenticatedFulfilled).toBe(true); + expect(promiseFulfilled).toEqual('succeeded'); + }); + + it('should update store after successful getSessionValid query', async () => { + mockedAxiosClient.onGet(apiPath.getSessionValid()).reply(HttpStatusCode.Ok, sessionFixture); + await store.dispatch(getSessionValid()); + const { authenticated, loading, login: sessionLogin } = store.getState().user; + + expect(authenticated).toBe(true); + expect(loading).toEqual('succeeded'); + expect(sessionLogin).toBeDefined(); + }); +}); diff --git a/src/redux/user/user.reducers.ts b/src/redux/user/user.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..56c847b9497038441472a5afc8bbb7ae5f488bf0 --- /dev/null +++ b/src/redux/user/user.reducers.ts @@ -0,0 +1,35 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getSessionValid, login } from './user.thunks'; +import { UserState } from './user.types'; + +export const loginReducer = (builder: ActionReducerMapBuilder<UserState>): void => { + builder + .addCase(login.pending, state => { + state.loading = 'pending'; + }) + .addCase(login.fulfilled, state => { + state.authenticated = true; + state.loading = 'succeeded'; + }) + .addCase(login.rejected, state => { + state.authenticated = false; + state.loading = 'failed'; + }); +}; + +export const getSessionValidReducer = (builder: ActionReducerMapBuilder<UserState>): void => { + builder + .addCase(getSessionValid.pending, state => { + state.loading = 'pending'; + }) + .addCase(getSessionValid.fulfilled, (state, action) => { + state.authenticated = true; + state.loading = 'succeeded'; + state.login = action.payload; + }) + .addCase(getSessionValid.rejected, state => { + state.authenticated = false; + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); +}; diff --git a/src/redux/user/user.selectors.ts b/src/redux/user/user.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..5331af9df0173163e2383de42e0a2800a9414b15 --- /dev/null +++ b/src/redux/user/user.selectors.ts @@ -0,0 +1,7 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const userSelector = createSelector(rootSelector, state => state.user); + +export const authenticatedUserSelector = createSelector(userSelector, state => state.authenticated); +export const loadingUserSelector = createSelector(userSelector, state => state.loading); diff --git a/src/redux/user/user.slice.ts b/src/redux/user/user.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8d9ce87b3cf40a3ff1e76fccfd93e41b97f20fb --- /dev/null +++ b/src/redux/user/user.slice.ts @@ -0,0 +1,22 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { getSessionValidReducer, loginReducer } from './user.reducers'; +import { UserState } from './user.types'; + +export const initialState: UserState = { + loading: 'idle', + authenticated: false, + error: { name: '', message: '' }, + login: null, +}; + +export const userSlice = createSlice({ + name: 'user', + initialState, + reducers: {}, + extraReducers: builder => { + loginReducer(builder); + getSessionValidReducer(builder); + }, +}); + +export default userSlice.reducer; diff --git a/src/redux/user/user.thunks.test.ts b/src/redux/user/user.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..982274d9bd4d6190f7eb3fb4f7196f2ff8b38d70 --- /dev/null +++ b/src/redux/user/user.thunks.test.ts @@ -0,0 +1,53 @@ +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { loginFixture } from '@/models/fixtures/loginFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { apiPath } from '../apiPath'; +import { closeModal } from '../modal/modal.slice'; +import userReducer from './user.slice'; +import { UserState } from './user.types'; +import { login } from './user.thunks'; + +const mockedAxiosClient = mockNetworkResponse(); +const CREDENTIALS = { + login: 'test', + password: 'password', +}; + +describe('login thunk', () => { + let store = {} as ToolkitStoreWithSingleSlice<UserState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('user', userReducer); + }); + + it('dispatches closeModal action on successful login with valid data', async () => { + mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); + + const mockDispatch = jest.fn(action => { + if (action.type === closeModal.type) { + expect(action).toEqual(closeModal()); + } + }); + + store.dispatch = mockDispatch; + + await store.dispatch(login(CREDENTIALS)); + }); + + it('does not dispatch closeModal action on failed login with invalid data', async () => { + mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.NotFound, loginFixture); + + const mockDispatch = jest.fn(action => { + if (action.type === closeModal.type) { + expect(action).not.toEqual(closeModal()); + } + }); + + store.dispatch = mockDispatch; + + await store.dispatch(login(CREDENTIALS)); + }); +}); diff --git a/src/redux/user/user.thunks.ts b/src/redux/user/user.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f4d97c26e526b7bdd779099340f5adae02231ea --- /dev/null +++ b/src/redux/user/user.thunks.ts @@ -0,0 +1,32 @@ +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { loginSchema } from '@/models/loginSchema'; +import { sessionSchemaValid } from '@/models/sessionValidSchema'; +import { apiPath } from '../apiPath'; +import { closeModal } from '../modal/modal.slice'; + +export const login = createAsyncThunk( + 'user/login', + async (credentials: { login: string; password: string }, { dispatch }) => { + const searchParams = new URLSearchParams(credentials); + const response = await axiosInstance.post(apiPath.postLogin(), searchParams, { + withCredentials: true, + }); + + const isDataValid = validateDataUsingZodSchema(response.data, loginSchema); + dispatch(closeModal()); + + return isDataValid ? response.data : undefined; + }, +); + +export const getSessionValid = createAsyncThunk('user/getSessionValid', async () => { + const response = await axiosInstance.get(apiPath.getSessionValid(), { + withCredentials: true, + }); + + const isDataValid = validateDataUsingZodSchema(response.data, sessionSchemaValid); + + return isDataValid ? response.data : undefined; +}); diff --git a/src/redux/user/user.types.ts b/src/redux/user/user.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..db28342dc0349e2b9f0eb407edcfd13be2ce0338 --- /dev/null +++ b/src/redux/user/user.types.ts @@ -0,0 +1,8 @@ +import { Loading } from '@/types/loadingState'; + +export type UserState = { + loading: Loading; + error: Error; + authenticated: boolean; + login: null | string; +}; diff --git a/src/types/modal.ts b/src/types/modal.ts index edfac1fd79b3807f8832ed64e269e612d4d816c7..aaf5031234aa385303bd30a4c1a6eb24e825f1fa 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -1 +1 @@ -export type ModalName = 'none' | 'overview-images'; +export type ModalName = 'none' | 'overview-images' | 'login'; diff --git a/src/types/models.ts b/src/types/models.ts index f01e8f5f79369a2049c5c3899e2f9fdae934596c..a1e01c3dae9d2478907f9de7d29fd4fec251f9d0 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -7,6 +7,7 @@ import { configurationOptionSchema } from '@/models/configurationOptionSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; +import { loginSchema } from '@/models/loginSchema'; import { mapBackground } from '@/models/mapBackground'; import { mapOverlay } from '@/models/mapOverlay'; import { mapModelSchema } from '@/models/modelSchema'; @@ -22,6 +23,7 @@ import { projectSchema } from '@/models/project'; import { reactionSchema } from '@/models/reaction'; import { reactionLineSchema } from '@/models/reactionLineSchema'; import { referenceSchema } from '@/models/referenceSchema'; +import { sessionSchemaValid } from '@/models/sessionValidSchema'; import { targetSchema } from '@/models/targetSchema'; import { z } from 'zod'; @@ -46,6 +48,8 @@ export type Reference = z.infer<typeof referenceSchema>; export type ReactionLine = z.infer<typeof reactionLineSchema>; export type ElementSearchResult = z.infer<typeof elementSearchResult>; export type ElementSearchResultType = z.infer<typeof elementSearchResultType>; +export type SessionValid = z.infer<typeof sessionSchemaValid>; +export type Login = z.infer<typeof loginSchema>; export type ConfigurationOption = z.infer<typeof configurationOptionSchema>; export type OverlayBioEntity = z.infer<typeof overlayBioEntitySchema>; export type Color = z.infer<typeof colorSchema>;