diff --git a/src/assets/images/orcid.png b/src/assets/images/orcid.png new file mode 100644 index 0000000000000000000000000000000000000000..2db382dfc069b8a098c876a8d38add9977bc406a Binary files /dev/null and b/src/assets/images/orcid.png differ diff --git a/src/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component.tsx b/src/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component.tsx index fc5ec255d90bf6f7ae1cefdd56539e85c1bbf7db..2cd0b46b08fd232978af3fe8db18240334b1ec0e 100644 --- a/src/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component.tsx +++ b/src/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component.tsx @@ -7,6 +7,7 @@ import { MINUS_ONE, ZERO } from '@/constants/common'; import { Button } from '@/shared/Button'; import { adminEmailValSelector } from '@/redux/configuration/configuration.selectors'; import { projectsSelector } from '@/redux/projects/projects.selectors'; +import { getOAuth } from '@/redux/oauth/oauth.thunks'; export const AccessDeniedModal: React.FC = () => { const dispatch = useAppDispatch(); @@ -28,6 +29,7 @@ export const AccessDeniedModal: React.FC = () => { const handleLogin = async (e: React.FormEvent<HTMLButtonElement>): Promise<void> => { e.preventDefault(); + dispatch(getOAuth()); dispatch(openLoginModal()); }; diff --git a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx index 3fa89a01282b34bd7a1dec3b1b3698f260ea16c8..58e6e2f672b5120cb8a691f118352b34c12e6307 100644 --- a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx @@ -7,6 +7,10 @@ import { Button } from '@/shared/Button'; import { Input } from '@/shared/Input'; import Link from 'next/link'; import React from 'react'; +import { BASE_API_URL } from '@/constants'; +import { orcidEndpointSelector } from '@/redux/oauth/oauth.selectors'; +import Image from 'next/image'; +import orcidLogoImg from '@/assets/images/orcid.png'; export const LoginModal: React.FC = () => { const dispatch = useAppDispatch(); @@ -14,11 +18,19 @@ export const LoginModal: React.FC = () => { const isPending = loadingUser === 'pending'; const [credentials, setCredentials] = React.useState({ login: '', password: '' }); + const orcidEndpoint = useAppSelector(orcidEndpointSelector); + + const isOrcidAvailable = orcidEndpoint !== undefined; + const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => { const { name, value } = e.target; setCredentials(prevCredentials => ({ ...prevCredentials, [name]: value })); }; + const handleLoginViaOrcid = (): void => { + window.location.href = `${BASE_API_URL}/..${orcidEndpoint}`; + }; + const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => { e.preventDefault(); await dispatch(login(credentials)); @@ -57,6 +69,16 @@ export const LoginModal: React.FC = () => { Forgot password? </Link> </div> + {isOrcidAvailable && ( + <Button + variantStyles="quiet" + className="mb-1 w-full justify-center text-base font-medium" + onClick={handleLoginViaOrcid} + > + <Image src={orcidLogoImg} alt="orcid logo" height={32} width={32} className="mr-1.5" /> + Sign in with Orcid + </Button> + )} <Button type="submit" className="w-full justify-center text-base font-medium" diff --git a/src/components/FunctionalArea/TopBar/User/User.component.test.tsx b/src/components/FunctionalArea/TopBar/User/User.component.test.tsx index 6198869f43d302eedd7269fe183e1622eed445a3..707e0f5dac4dac4709f7d47e1eef9e5ee3e226ba 100644 --- a/src/components/FunctionalArea/TopBar/User/User.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/User/User.component.test.tsx @@ -8,6 +8,7 @@ import { apiPath } from '@/redux/apiPath'; import { HttpStatusCode } from 'axios'; import mockRouter from 'next-router-mock'; import { projectFixture } from '@/models/fixtures/projectFixture'; +import { oauthFixture } from '@/models/fixtures/oauthFixture'; import { User } from './User.component'; const mockedAxiosClient = mockNetworkResponse(); @@ -131,6 +132,8 @@ describe('AuthenticatedUser component', () => { }); it('should display login modal if switch account is pressed', async () => { + mockedAxiosClient.onGet(apiPath.getOauthProviders()).reply(HttpStatusCode.Ok, oauthFixture); + const { store } = renderComponent({ user: { ...USER_INITIAL_STATE_MOCK, diff --git a/src/components/FunctionalArea/TopBar/User/hooks/useUserActions.ts b/src/components/FunctionalArea/TopBar/User/hooks/useUserActions.ts index dd5b22725de0bb9da3a296a43d2597c62e7e4c4a..f1c0f845b995fe380cd750a38acbf3d7b874631b 100644 --- a/src/components/FunctionalArea/TopBar/User/hooks/useUserActions.ts +++ b/src/components/FunctionalArea/TopBar/User/hooks/useUserActions.ts @@ -8,6 +8,7 @@ import { useRouter } from 'next/router'; import { USER_ROLE } from '@/constants/user'; import { useMemo } from 'react'; import { CURRENT_PROJECT_ADMIN_PANEL_URL } from '@/constants'; +import { getOAuth } from '@/redux/oauth/oauth.thunks'; import { ADMIN_CURATOR_ACTIONS, BASE_ACTIONS } from '../User.constants'; type UseUserActionsReturnType = { @@ -33,6 +34,7 @@ export const useUserActions = (): UseUserActionsReturnType => { }, [userRole]); const openModalLogin = (): void => { + dispatch(getOAuth()); dispatch(openLoginModal()); }; diff --git a/src/models/fixtures/oauthFixture.ts b/src/models/fixtures/oauthFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..753b8da35f060089663aadb518d34b7bee7b1b4e --- /dev/null +++ b/src/models/fixtures/oauthFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { oauthSchema } from '@/models/oauthSchema'; + +export const oauthFixture = createFixture(oauthSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/oauthSchema.ts b/src/models/oauthSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9370c9d2876ad0aa7fff55142461c9b9b7dcea7 --- /dev/null +++ b/src/models/oauthSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const oauthSchema = z.object({ + Orcid: z.string().optional(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 22f16e77a7483b0fdd2f86ce20aaee85bc1f5c2f..8227bfec287407fd9cc09415b7176beb6ed914bb 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -101,6 +101,7 @@ export const apiPath = { user: (login: string): string => `users/${login}`, getStacktrace: (code: string): string => `stacktrace/${code}`, submitError: (): string => `minervanet/submitError`, + getOauthProviders: (): string => `oauth/providers/`, userPrivileges: (login: string): string => `users/${login}?columns=privileges`, getComments: (): string => `projects/${PROJECT_ID}/comments/models/*/`, addComment: (modelId: number, x: number, y: number): string => diff --git a/src/redux/oauth/oauth.constants.ts b/src/redux/oauth/oauth.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc92f1da5ab570919841515581dd6be8a4988ea2 --- /dev/null +++ b/src/redux/oauth/oauth.constants.ts @@ -0,0 +1 @@ +export const OAUTH_FETCHING_ERROR_PREFIX = 'Failed to fetch oauth providers'; diff --git a/src/redux/oauth/oauth.fixtures.ts b/src/redux/oauth/oauth.fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b8212184250c69c348ce5fae7c8ac8392a914b8 --- /dev/null +++ b/src/redux/oauth/oauth.fixtures.ts @@ -0,0 +1,5 @@ +import { OAuth } from '@/types/models'; + +export const initialOAuthFixture: OAuth = { + Orcid: undefined, +}; diff --git a/src/redux/oauth/oauth.mock.ts b/src/redux/oauth/oauth.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1b18f8f02b1e79c503f6729793a82721cbf956e --- /dev/null +++ b/src/redux/oauth/oauth.mock.ts @@ -0,0 +1,10 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { OauthState } from '@/redux/oauth/oauth.types'; +import { initialOAuthFixture } from '@/redux/oauth/oauth.fixtures'; + +export const OAUTH_INITIAL_STATE_MOCK: OauthState = { + data: initialOAuthFixture, + loading: 'idle', + error: DEFAULT_ERROR, + orcidEndpoint: undefined, +}; diff --git a/src/redux/oauth/oauth.reducers.ts b/src/redux/oauth/oauth.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e5cce6516930ce2c66cfc37c86b864a6ff8f740 --- /dev/null +++ b/src/redux/oauth/oauth.reducers.ts @@ -0,0 +1,20 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { OauthState } from '@/redux/oauth/oauth.types'; +import { getOAuth } from '@/redux/oauth/oauth.thunks'; + +export const getOauthReducer = (builder: ActionReducerMapBuilder<OauthState>): void => { + builder.addCase(getOAuth.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getOAuth.fulfilled, (state, action) => { + state.data = action.payload || undefined; + if (action.payload && action.payload.Orcid) { + state.orcidEndpoint = action.payload.Orcid; + } + state.loading = 'succeeded'; + }); + builder.addCase(getOAuth.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/oauth/oauth.selectors.ts b/src/redux/oauth/oauth.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bda1780c56eb0804cfbc0e53935a6d6e492fc0d --- /dev/null +++ b/src/redux/oauth/oauth.selectors.ts @@ -0,0 +1,6 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '../root/root.selectors'; + +export const oauthSelector = createSelector(rootSelector, state => state.oauth); + +export const orcidEndpointSelector = createSelector(oauthSelector, oauth => oauth?.orcidEndpoint); diff --git a/src/redux/oauth/oauth.slice.ts b/src/redux/oauth/oauth.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..8abb3ba8feb2a9b9414d529710cdbfc367f402ca --- /dev/null +++ b/src/redux/oauth/oauth.slice.ts @@ -0,0 +1,21 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { OauthState } from '@/redux/oauth/oauth.types'; +import { getOauthReducer } from '@/redux/oauth/oauth.reducers'; + +const initialState: OauthState = { + data: undefined, + loading: 'idle', + error: { name: '', message: '' }, + orcidEndpoint: undefined, +}; + +const oauthSlice = createSlice({ + name: 'oauth', + initialState, + reducers: {}, + extraReducers: builder => { + getOauthReducer(builder); + }, +}); + +export default oauthSlice.reducer; diff --git a/src/redux/oauth/oauth.thunks.ts b/src/redux/oauth/oauth.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..42907946419bc86ca8befe0794a16661aec26da7 --- /dev/null +++ b/src/redux/oauth/oauth.thunks.ts @@ -0,0 +1,24 @@ +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { OAuth } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from '@/types/store'; +import { getError } from '@/utils/error-report/getError'; +import { oauthSchema } from '@/models/oauthSchema'; +import { OAUTH_FETCHING_ERROR_PREFIX } from '@/redux/oauth/oauth.constants'; +import { apiPath } from '../apiPath'; + +export const getOAuth = createAsyncThunk<OAuth | undefined, void, ThunkConfig>( + 'oauth/getProviders', + async () => { + try { + const response = await axiosInstance.get<OAuth>(apiPath.getOauthProviders()); + + const isDataValid = validateDataUsingZodSchema(response.data, oauthSchema); + + return isDataValid ? response.data : undefined; + } catch (error) { + return Promise.reject(getError({ error, prefix: OAUTH_FETCHING_ERROR_PREFIX })); + } + }, +); diff --git a/src/redux/oauth/oauth.types.ts b/src/redux/oauth/oauth.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..2992b701903eca04a3792cfed87a85b5388ca170 --- /dev/null +++ b/src/redux/oauth/oauth.types.ts @@ -0,0 +1,9 @@ +import { OAuth } from '@/types/models'; +import { Loading } from '@/types/loadingState'; + +export type OauthState = { + orcidEndpoint: string | undefined; + data: OAuth | undefined; + loading: Loading; + error: Error; +}; diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 0f6962d29a1615c73c2fbb6de27a929e8a20d3a5..421151bd9d870052cc5137a3f66f7c3451b494e8 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -1,5 +1,6 @@ import { CONSTANT_INITIAL_STATE } from '@/redux/constant/constant.adapter'; import { PROJECTS_STATE_INITIAL_MOCK } from '@/redux/projects/projects.mock'; +import { OAUTH_INITIAL_STATE_MOCK } from '@/redux/oauth/oauth.mock'; import { COMMENT_INITIAL_STATE_MOCK } from '@/redux/comment/comment.mock'; import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock'; @@ -39,6 +40,7 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { backgrounds: BACKGROUND_INITIAL_STATE_MOCK, drawer: drawerInitialStateMock, map: initialMapStateFixture, + oauth: OAUTH_INITIAL_STATE_MOCK, overlays: OVERLAYS_INITIAL_STATE_MOCK, reactions: REACTIONS_STATE_INITIAL_MOCK, configuration: CONFIGURATION_INITIAL_STATE, diff --git a/src/redux/store.ts b/src/redux/store.ts index e7649c165cbd19dc9ef1d42ba807fddec7172518..ebc65642cab5db9e0a830989779bdaaceec80450 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -10,6 +10,7 @@ import drugsReducer from '@/redux/drugs/drugs.slice'; import mapReducer from '@/redux/map/map.slice'; import modalReducer from '@/redux/modal/modal.slice'; import modelsReducer from '@/redux/models/models.slice'; +import oauthReducer from '@/redux/oauth/oauth.slice'; import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import overlaysReducer from '@/redux/overlays/overlays.slice'; import projectReducer from '@/redux/project/project.slice'; @@ -65,6 +66,7 @@ export const reducers = { plugins: pluginsReducer, markers: markersReducer, entityNumber: entityNumberReducer, + oauth: oauthReducer, }; export const middlewares = [mapListenerMiddleware.middleware, errorListenerMiddleware.middleware]; diff --git a/src/types/models.ts b/src/types/models.ts index 044d36d8983998a08941364dedcffa28287990a6..c55955ac291dc9571cd2879f730777ebb663682e 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -64,6 +64,7 @@ import { z } from 'zod'; import { commentSchema } from '@/models/commentSchema'; import { userSchema } from '@/models/userSchema'; import { javaStacktraceSchema } from '@/models/javaStacktraceSchema'; +import { oauthSchema } from '@/models/oauthSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -133,3 +134,5 @@ export type PageOf<T> = { number: number; content: T[]; }; + +export type OAuth = z.infer<typeof oauthSchema>;