Skip to content
Snippets Groups Projects
Commit 3c109b0f authored by Piotr Gawron's avatar Piotr Gawron
Browse files

Merge branch '253-min-320-opening-project-without-permission' into 'development'

Resolve "[MIN-320] opening project without permission"

Closes #253

See merge request !204
parents 37250688 f8746b1b
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!204Resolve "[MIN-320] opening project without permission"
Pipeline #92322 passed
Showing
with 202 additions and 14 deletions
src/assets/images/orcid.png

2.08 KiB

import React from 'react';
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { loginUserSelector } from '@/redux/user/user.selectors';
import { openLoginModal } from '@/redux/modal/modal.slice';
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();
const login = useAppSelector(loginUserSelector);
const projects = useAppSelector(projectsSelector);
const adminEmail = useAppSelector(adminEmailValSelector);
const isAnonymousLogin = !login;
const isProjectsAvailable = projects.length > ZERO;
const isAdminEmail = adminEmail !== '' && adminEmail !== undefined;
const handleGoBack = async (e: React.FormEvent<HTMLButtonElement>): Promise<void> => {
e.preventDefault();
window.history.go(MINUS_ONE);
};
const handleLogin = async (e: React.FormEvent<HTMLButtonElement>): Promise<void> => {
e.preventDefault();
dispatch(getOAuth());
dispatch(openLoginModal());
};
const handleContactAmin = async (e: React.FormEvent<HTMLButtonElement>): Promise<void> => {
e.preventDefault();
window.location.href = `mailto:${adminEmail}`;
};
const openProject = (e: React.FormEvent<HTMLButtonElement>): void => {
window.location.href = `?id=${e.currentTarget.value}`;
};
return (
<div className="w-[400px] border border-t-[#E1E0E6] bg-white p-[24px]">
{isAnonymousLogin && (
<div className="grid grid-cols-2 gap-2">
<div>
<Button
className="ring-transparent hover:ring-transparent"
variantStyles="secondary"
onClick={handleGoBack}
>
Go back to previous page
</Button>
</div>
<div className="text-center">
<Button className="block w-full" onClick={handleLogin}>
Login to your account
</Button>
</div>
</div>
)}
{isProjectsAvailable && (
<div>
<div className="mb-1 mt-5 text-sm">Switch to another map</div>
<div className="grid grid-cols-3 gap-2">
{projects.map(project => (
<Button
key={project.projectId}
value={project.projectId}
variantStyles="ghost"
className="text-center text-gray-500"
onClick={openProject}
>
{project.name} ({project.projectId})
</Button>
))}
</div>
</div>
)}
{isAdminEmail && (
<div className="mt-1 text-center">
<Button
className="block w-full ring-transparent hover:ring-transparent"
variantStyles="secondary"
onClick={handleContactAmin}
>
Contact admin
</Button>
</div>
)}
</div>
);
};
...@@ -7,6 +7,10 @@ import { Button } from '@/shared/Button'; ...@@ -7,6 +7,10 @@ import { Button } from '@/shared/Button';
import { Input } from '@/shared/Input'; import { Input } from '@/shared/Input';
import Link from 'next/link'; import Link from 'next/link';
import React from 'react'; 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 = () => { export const LoginModal: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
...@@ -14,11 +18,19 @@ export const LoginModal: React.FC = () => { ...@@ -14,11 +18,19 @@ export const LoginModal: React.FC = () => {
const isPending = loadingUser === 'pending'; const isPending = loadingUser === 'pending';
const [credentials, setCredentials] = React.useState({ login: '', password: '' }); const [credentials, setCredentials] = React.useState({ login: '', password: '' });
const orcidEndpoint = useAppSelector(orcidEndpointSelector);
const isOrcidAvailable = orcidEndpoint !== undefined;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const { name, value } = e.target; const { name, value } = e.target;
setCredentials(prevCredentials => ({ ...prevCredentials, [name]: value })); setCredentials(prevCredentials => ({ ...prevCredentials, [name]: value }));
}; };
const handleLoginViaOrcid = (): void => {
window.location.href = `${BASE_API_URL}/..${orcidEndpoint}`;
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault(); e.preventDefault();
await dispatch(login(credentials)); await dispatch(login(credentials));
...@@ -57,6 +69,16 @@ export const LoginModal: React.FC = () => { ...@@ -57,6 +69,16 @@ export const LoginModal: React.FC = () => {
Forgot password? Forgot password?
</Link> </Link>
</div> </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 <Button
type="submit" type="submit"
className="w-full justify-center text-base font-medium" className="w-full justify-center text-base font-medium"
......
import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { modalSelector } from '@/redux/modal/modal.selector'; import { modalSelector } from '@/redux/modal/modal.selector';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { AccessDeniedModal } from '@/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component';
import { AddCommentModal } from '@/components/FunctionalArea/Modal/AddCommentModal/AddCommentModal.component'; import { AddCommentModal } from '@/components/FunctionalArea/Modal/AddCommentModal/AddCommentModal.component';
import { EditOverlayModal } from './EditOverlayModal'; import { EditOverlayModal } from './EditOverlayModal';
import { LoginModal } from './LoginModal'; import { LoginModal } from './LoginModal';
...@@ -51,6 +52,11 @@ export const Modal = (): React.ReactNode => { ...@@ -51,6 +52,11 @@ export const Modal = (): React.ReactNode => {
<LoggedInMenuModal /> <LoggedInMenuModal />
</ModalLayout> </ModalLayout>
)} )}
{isOpen && modalName === 'access-denied' && (
<ModalLayout>
<AccessDeniedModal />
</ModalLayout>
)}
{isOpen && modalName === 'add-comment' && ( {isOpen && modalName === 'add-comment' && (
<ModalLayout> <ModalLayout>
<AddCommentModal /> <AddCommentModal />
......
...@@ -28,19 +28,22 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => { ...@@ -28,19 +28,22 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => {
className={twMerge( className={twMerge(
'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg', 'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg',
modalName === 'login' && 'h-auto w-[400px]', modalName === 'login' && 'h-auto w-[400px]',
modalName === 'access-denied' && 'h-auto w-[400px]',
modalName === 'add-comment' && 'h-auto w-[400px]', modalName === 'add-comment' && 'h-auto w-[400px]',
modalName === 'error-report' && 'h-auto w-[800px]', modalName === 'error-report' && 'h-auto w-[800px]',
['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]', ['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]',
)} )}
> >
<div className="flex items-center justify-between bg-white p-[24px] text-xl"> <div className="flex items-center justify-between bg-white p-[24px] text-xl">
{modalName === 'error-report' && ( {(modalName === 'error-report' || modalName === 'access-denied') && (
<div className="font-bold text-red-500"> <div className="font-bold text-red-500">
<Icon name="info" className={twMerge('mr-4 fill-red-500')} /> <Icon name="info" className={twMerge('mr-4 fill-red-500')} />
{modalTitle} {modalTitle}
</div> </div>
)} )}
{modalName !== 'error-report' && <div> {modalTitle} </div>} {modalName !== 'error-report' && modalName !== 'access-denied' && (
<div> {modalTitle} </div>
)}
{modalName !== 'logged-in-menu' && ( {modalName !== 'logged-in-menu' && (
<button type="button" onClick={handleCloseModal} aria-label="close button"> <button type="button" onClick={handleCloseModal} aria-label="close button">
......
...@@ -8,6 +8,7 @@ import { apiPath } from '@/redux/apiPath'; ...@@ -8,6 +8,7 @@ import { apiPath } from '@/redux/apiPath';
import { HttpStatusCode } from 'axios'; import { HttpStatusCode } from 'axios';
import mockRouter from 'next-router-mock'; import mockRouter from 'next-router-mock';
import { projectFixture } from '@/models/fixtures/projectFixture'; import { projectFixture } from '@/models/fixtures/projectFixture';
import { oauthFixture } from '@/models/fixtures/oauthFixture';
import { User } from './User.component'; import { User } from './User.component';
const mockedAxiosClient = mockNetworkResponse(); const mockedAxiosClient = mockNetworkResponse();
...@@ -131,6 +132,8 @@ describe('AuthenticatedUser component', () => { ...@@ -131,6 +132,8 @@ describe('AuthenticatedUser component', () => {
}); });
it('should display login modal if switch account is pressed', async () => { it('should display login modal if switch account is pressed', async () => {
mockedAxiosClient.onGet(apiPath.getOauthProviders()).reply(HttpStatusCode.Ok, oauthFixture);
const { store } = renderComponent({ const { store } = renderComponent({
user: { user: {
...USER_INITIAL_STATE_MOCK, ...USER_INITIAL_STATE_MOCK,
......
...@@ -8,6 +8,7 @@ import { useRouter } from 'next/router'; ...@@ -8,6 +8,7 @@ import { useRouter } from 'next/router';
import { USER_ROLE } from '@/constants/user'; import { USER_ROLE } from '@/constants/user';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { CURRENT_PROJECT_ADMIN_PANEL_URL } from '@/constants'; import { CURRENT_PROJECT_ADMIN_PANEL_URL } from '@/constants';
import { getOAuth } from '@/redux/oauth/oauth.thunks';
import { ADMIN_CURATOR_ACTIONS, BASE_ACTIONS } from '../User.constants'; import { ADMIN_CURATOR_ACTIONS, BASE_ACTIONS } from '../User.constants';
type UseUserActionsReturnType = { type UseUserActionsReturnType = {
...@@ -33,6 +34,7 @@ export const useUserActions = (): UseUserActionsReturnType => { ...@@ -33,6 +34,7 @@ export const useUserActions = (): UseUserActionsReturnType => {
}, [userRole]); }, [userRole]);
const openModalLogin = (): void => { const openModalLogin = (): void => {
dispatch(getOAuth());
dispatch(openLoginModal()); dispatch(openLoginModal());
}; };
......
...@@ -2,6 +2,8 @@ export const SIZE_OF_EMPTY_ARRAY = 0; ...@@ -2,6 +2,8 @@ export const SIZE_OF_EMPTY_ARRAY = 0;
export const SIZE_OF_ARRAY_WITH_FOUR_ELEMENTS = 4; export const SIZE_OF_ARRAY_WITH_FOUR_ELEMENTS = 4;
export const SIZE_OF_ARRAY_WITH_ONE_ELEMENT = 1; export const SIZE_OF_ARRAY_WITH_ONE_ELEMENT = 1;
export const MINUS_ONE = -1;
export const ZERO = 0; export const ZERO = 0;
export const FIRST_ARRAY_ELEMENT = 0; export const FIRST_ARRAY_ELEMENT = 0;
......
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,
});
import { ZOD_SEED } from '@/constants';
// eslint-disable-next-line import/no-extraneous-dependencies
import { createFixture } from 'zod-fixture';
import { pageableSchema } from '@/models/pageableSchema';
import { projectSchema } from '../projectSchema';
export const projectPageFixture = createFixture(pageableSchema(projectSchema), {
seed: ZOD_SEED,
array: { min: 1, max: 1 },
});
import { z } from 'zod';
export const oauthSchema = z.object({
Orcid: z.string().optional(),
});
import { z } from 'zod';
import { ZodTypeAny } from 'zod/lib/types';
export const pageableSchema = <T extends ZodTypeAny>(type: T): ZodTypeAny =>
z.object({
totalPages: z.number().nonnegative(),
totalElements: z.number().nonnegative(),
numberOfElements: z.number().nonnegative(),
size: z.number().positive(),
number: z.number().nonnegative(),
content: z.array(type),
});
...@@ -54,6 +54,7 @@ export const apiPath = { ...@@ -54,6 +54,7 @@ export const apiPath = {
getAllBackgroundsByProjectIdQuery: (projectId: string): string => getAllBackgroundsByProjectIdQuery: (projectId: string): string =>
`projects/${projectId}/backgrounds/`, `projects/${projectId}/backgrounds/`,
getProjectById: (projectId: string): string => `projects/${projectId}`, getProjectById: (projectId: string): string => `projects/${projectId}`,
getProjects: (): string => `projects/`,
getSessionValid: (): string => `users/isSessionValid`, getSessionValid: (): string => `users/isSessionValid`,
postLogin: (): string => `doLogin`, postLogin: (): string => `doLogin`,
getConfigurationOptions: (): string => 'configuration/options/', getConfigurationOptions: (): string => 'configuration/options/',
...@@ -100,6 +101,7 @@ export const apiPath = { ...@@ -100,6 +101,7 @@ export const apiPath = {
user: (login: string): string => `users/${login}`, user: (login: string): string => `users/${login}`,
getStacktrace: (code: string): string => `stacktrace/${code}`, getStacktrace: (code: string): string => `stacktrace/${code}`,
submitError: (): string => `minervanet/submitError`, submitError: (): string => `minervanet/submitError`,
getOauthProviders: (): string => `oauth/providers/`,
userPrivileges: (login: string): string => `users/${login}?columns=privileges`, userPrivileges: (login: string): string => `users/${login}?columns=privileges`,
getComments: (): string => `projects/${PROJECT_ID}/comments/models/*/`, getComments: (): string => `projects/${PROJECT_ID}/comments/models/*/`,
addComment: (modelId: number, x: number, y: number): string => addComment: (modelId: number, x: number, y: number): string =>
......
...@@ -4,6 +4,7 @@ export const SIMPLE_COLOR_VAL_NAME_ID = 'SIMPLE_COLOR_VAL'; ...@@ -4,6 +4,7 @@ export const SIMPLE_COLOR_VAL_NAME_ID = 'SIMPLE_COLOR_VAL';
export const NEUTRAL_COLOR_VAL_NAME_ID = 'NEUTRAL_COLOR_VAL'; export const NEUTRAL_COLOR_VAL_NAME_ID = 'NEUTRAL_COLOR_VAL';
export const OVERLAY_OPACITY_NAME_ID = 'OVERLAY_OPACITY'; export const OVERLAY_OPACITY_NAME_ID = 'OVERLAY_OPACITY';
export const SEARCH_DISTANCE_NAME_ID = 'SEARCH_DISTANCE'; export const SEARCH_DISTANCE_NAME_ID = 'SEARCH_DISTANCE';
export const REQUEST_ACCOUNT_EMAIL = 'REQUEST_ACCOUNT_EMAIL';
export const LEGEND_FILE_NAMES_IDS = [ export const LEGEND_FILE_NAMES_IDS = [
'LEGEND_FILE_1', 'LEGEND_FILE_1',
......
...@@ -17,7 +17,9 @@ import { ...@@ -17,7 +17,9 @@ import {
SIMPLE_COLOR_VAL_NAME_ID, SIMPLE_COLOR_VAL_NAME_ID,
SVG_IMAGE_HANDLER_NAME_ID, SVG_IMAGE_HANDLER_NAME_ID,
SEARCH_DISTANCE_NAME_ID, SEARCH_DISTANCE_NAME_ID,
REQUEST_ACCOUNT_EMAIL,
} from './configuration.constants'; } from './configuration.constants';
import { ConfigurationHandlersIds, ConfigurationImageHandlersIds } from './configuration.types'; import { ConfigurationHandlersIds, ConfigurationImageHandlersIds } from './configuration.types';
const configurationSelector = createSelector(rootSelector, state => state.configuration); const configurationSelector = createSelector(rootSelector, state => state.configuration);
...@@ -56,6 +58,11 @@ export const searchDistanceValSelector = createSelector( ...@@ -56,6 +58,11 @@ export const searchDistanceValSelector = createSelector(
state => configurationAdapterSelectors.selectById(state, SEARCH_DISTANCE_NAME_ID)?.value, state => configurationAdapterSelectors.selectById(state, SEARCH_DISTANCE_NAME_ID)?.value,
); );
export const adminEmailValSelector = createSelector(
configurationOptionsSelector,
state => configurationAdapterSelectors.selectById(state, REQUEST_ACCOUNT_EMAIL)?.value,
);
export const defaultLegendImagesSelector = createSelector(configurationOptionsSelector, state => export const defaultLegendImagesSelector = createSelector(configurationOptionsSelector, state =>
LEGEND_FILE_NAMES_IDS.map( LEGEND_FILE_NAMES_IDS.map(
legendNameId => configurationAdapterSelectors.selectById(state, legendNameId)?.value, legendNameId => configurationAdapterSelectors.selectById(state, legendNameId)?.value,
......
import { store } from '@/redux/store'; import { store } from '@/redux/store';
import { showToast } from '@/utils/showToast';
import { errorMiddlewareListener } from './error.middleware'; import { errorMiddlewareListener } from './error.middleware';
jest.mock('../../utils/showToast'); jest.mock('../../utils/showToast');
...@@ -132,7 +132,7 @@ describe('errorMiddlewareListener', () => { ...@@ -132,7 +132,7 @@ describe('errorMiddlewareListener', () => {
); );
}); });
it('should toast on access denied', async () => { it('should show modal on access denied', async () => {
const action = { const action = {
type: 'action/rejected', type: 'action/rejected',
payload: null, payload: null,
...@@ -149,9 +149,10 @@ describe('errorMiddlewareListener', () => { ...@@ -149,9 +149,10 @@ describe('errorMiddlewareListener', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error
await errorMiddlewareListener(action, { getState, dispatch }); await errorMiddlewareListener(action, { getState, dispatch });
expect(showToast).toHaveBeenCalledWith({ expect(dispatchSpy).toHaveBeenCalledWith(
message: 'Access denied.', expect.objectContaining({
type: 'error', type: 'modal/openAccessDeniedModal',
}); }),
);
}); });
}); });
import type { AppListenerEffectAPI, AppStartListening } from '@/redux/store'; import type { AppListenerEffectAPI, AppStartListening } from '@/redux/store';
import { Action, createListenerMiddleware, isRejected } from '@reduxjs/toolkit'; import { Action, createListenerMiddleware, isRejected } from '@reduxjs/toolkit';
import { createErrorData } from '@/utils/error-report/errorReporting'; import { createErrorData } from '@/utils/error-report/errorReporting';
import { openErrorReportModal } from '@/redux/modal/modal.slice'; import { openAccessDeniedModal, openErrorReportModal } from '@/redux/modal/modal.slice';
import { showToast } from '@/utils/showToast'; import { getProjects } from '@/redux/projects/projects.thunks';
export const errorListenerMiddleware = createListenerMiddleware(); export const errorListenerMiddleware = createListenerMiddleware();
...@@ -14,10 +14,8 @@ export const errorMiddlewareListener = async ( ...@@ -14,10 +14,8 @@ export const errorMiddlewareListener = async (
): Promise<void> => { ): Promise<void> => {
if (isRejected(action) && action.type !== 'user/getSessionValid/rejected') { if (isRejected(action) && action.type !== 'user/getSessionValid/rejected') {
if (action.error.code === '403') { if (action.error.code === '403') {
showToast({ dispatch(getProjects());
type: 'error', dispatch(openAccessDeniedModal());
message: 'Access denied.',
});
} else { } else {
const errorData = await createErrorData(action.error, getState()); const errorData = await createErrorData(action.error, getState());
dispatch(openErrorReportModal(errorData)); dispatch(openErrorReportModal(errorData));
......
...@@ -67,6 +67,12 @@ export const openErrorReportModalReducer = ( ...@@ -67,6 +67,12 @@ export const openErrorReportModalReducer = (
}; };
}; };
export const openAccessDeniedModalReducer = (state: ModalState): void => {
state.isOpen = true;
state.modalName = 'access-denied';
state.modalTitle = 'Access denied!';
};
export const setOverviewImageIdReducer = ( export const setOverviewImageIdReducer = (
state: ModalState, state: ModalState,
action: PayloadAction<number>, action: PayloadAction<number>,
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
openLoggedInMenuModalReducer, openLoggedInMenuModalReducer,
openAddCommentModalReducer, openAddCommentModalReducer,
openErrorReportModalReducer, openErrorReportModalReducer,
openAccessDeniedModalReducer,
} from './modal.reducers'; } from './modal.reducers';
const modalSlice = createSlice({ const modalSlice = createSlice({
...@@ -29,6 +30,7 @@ const modalSlice = createSlice({ ...@@ -29,6 +30,7 @@ const modalSlice = createSlice({
openEditOverlayModal: openEditOverlayModalReducer, openEditOverlayModal: openEditOverlayModalReducer,
openLoggedInMenuModal: openLoggedInMenuModalReducer, openLoggedInMenuModal: openLoggedInMenuModalReducer,
openErrorReportModal: openErrorReportModalReducer, openErrorReportModal: openErrorReportModalReducer,
openAccessDeniedModal: openAccessDeniedModalReducer,
}, },
}); });
...@@ -44,6 +46,7 @@ export const { ...@@ -44,6 +46,7 @@ export const {
openEditOverlayModal, openEditOverlayModal,
openLoggedInMenuModal, openLoggedInMenuModal,
openErrorReportModal, openErrorReportModal,
openAccessDeniedModal,
} = modalSlice.actions; } = modalSlice.actions;
export default modalSlice.reducer; export default modalSlice.reducer;
export const OAUTH_FETCHING_ERROR_PREFIX = 'Failed to fetch oauth providers';
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment