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';
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"
......
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { modalSelector } from '@/redux/modal/modal.selector';
import dynamic from 'next/dynamic';
import { AccessDeniedModal } from '@/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component';
import { AddCommentModal } from '@/components/FunctionalArea/Modal/AddCommentModal/AddCommentModal.component';
import { EditOverlayModal } from './EditOverlayModal';
import { LoginModal } from './LoginModal';
......@@ -51,6 +52,11 @@ export const Modal = (): React.ReactNode => {
<LoggedInMenuModal />
</ModalLayout>
)}
{isOpen && modalName === 'access-denied' && (
<ModalLayout>
<AccessDeniedModal />
</ModalLayout>
)}
{isOpen && modalName === 'add-comment' && (
<ModalLayout>
<AddCommentModal />
......
......@@ -28,19 +28,22 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => {
className={twMerge(
'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg',
modalName === 'login' && 'h-auto w-[400px]',
modalName === 'access-denied' && 'h-auto w-[400px]',
modalName === 'add-comment' && 'h-auto w-[400px]',
modalName === 'error-report' && 'h-auto w-[800px]',
['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]',
)}
>
<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">
<Icon name="info" className={twMerge('mr-4 fill-red-500')} />
{modalTitle}
</div>
)}
{modalName !== 'error-report' && <div> {modalTitle} </div>}
{modalName !== 'error-report' && modalName !== 'access-denied' && (
<div> {modalTitle} </div>
)}
{modalName !== 'logged-in-menu' && (
<button type="button" onClick={handleCloseModal} aria-label="close button">
......
......@@ -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,
......
......@@ -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());
};
......
......@@ -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_ONE_ELEMENT = 1;
export const MINUS_ONE = -1;
export const ZERO = 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 = {
getAllBackgroundsByProjectIdQuery: (projectId: string): string =>
`projects/${projectId}/backgrounds/`,
getProjectById: (projectId: string): string => `projects/${projectId}`,
getProjects: (): string => `projects/`,
getSessionValid: (): string => `users/isSessionValid`,
postLogin: (): string => `doLogin`,
getConfigurationOptions: (): string => 'configuration/options/',
......@@ -100,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 =>
......
......@@ -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 OVERLAY_OPACITY_NAME_ID = 'OVERLAY_OPACITY';
export const SEARCH_DISTANCE_NAME_ID = 'SEARCH_DISTANCE';
export const REQUEST_ACCOUNT_EMAIL = 'REQUEST_ACCOUNT_EMAIL';
export const LEGEND_FILE_NAMES_IDS = [
'LEGEND_FILE_1',
......
......@@ -17,7 +17,9 @@ import {
SIMPLE_COLOR_VAL_NAME_ID,
SVG_IMAGE_HANDLER_NAME_ID,
SEARCH_DISTANCE_NAME_ID,
REQUEST_ACCOUNT_EMAIL,
} from './configuration.constants';
import { ConfigurationHandlersIds, ConfigurationImageHandlersIds } from './configuration.types';
const configurationSelector = createSelector(rootSelector, state => state.configuration);
......@@ -56,6 +58,11 @@ export const searchDistanceValSelector = createSelector(
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 =>
LEGEND_FILE_NAMES_IDS.map(
legendNameId => configurationAdapterSelectors.selectById(state, legendNameId)?.value,
......
import { store } from '@/redux/store';
import { showToast } from '@/utils/showToast';
import { errorMiddlewareListener } from './error.middleware';
jest.mock('../../utils/showToast');
......@@ -132,7 +132,7 @@ describe('errorMiddlewareListener', () => {
);
});
it('should toast on access denied', async () => {
it('should show modal on access denied', async () => {
const action = {
type: 'action/rejected',
payload: null,
......@@ -149,9 +149,10 @@ describe('errorMiddlewareListener', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await errorMiddlewareListener(action, { getState, dispatch });
expect(showToast).toHaveBeenCalledWith({
message: 'Access denied.',
type: 'error',
});
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'modal/openAccessDeniedModal',
}),
);
});
});
import type { AppListenerEffectAPI, AppStartListening } from '@/redux/store';
import { Action, createListenerMiddleware, isRejected } from '@reduxjs/toolkit';
import { createErrorData } from '@/utils/error-report/errorReporting';
import { openErrorReportModal } from '@/redux/modal/modal.slice';
import { showToast } from '@/utils/showToast';
import { openAccessDeniedModal, openErrorReportModal } from '@/redux/modal/modal.slice';
import { getProjects } from '@/redux/projects/projects.thunks';
export const errorListenerMiddleware = createListenerMiddleware();
......@@ -14,10 +14,8 @@ export const errorMiddlewareListener = async (
): Promise<void> => {
if (isRejected(action) && action.type !== 'user/getSessionValid/rejected') {
if (action.error.code === '403') {
showToast({
type: 'error',
message: 'Access denied.',
});
dispatch(getProjects());
dispatch(openAccessDeniedModal());
} else {
const errorData = await createErrorData(action.error, getState());
dispatch(openErrorReportModal(errorData));
......
......@@ -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 = (
state: ModalState,
action: PayloadAction<number>,
......
......@@ -12,6 +12,7 @@ import {
openLoggedInMenuModalReducer,
openAddCommentModalReducer,
openErrorReportModalReducer,
openAccessDeniedModalReducer,
} from './modal.reducers';
const modalSlice = createSlice({
......@@ -29,6 +30,7 @@ const modalSlice = createSlice({
openEditOverlayModal: openEditOverlayModalReducer,
openLoggedInMenuModal: openLoggedInMenuModalReducer,
openErrorReportModal: openErrorReportModalReducer,
openAccessDeniedModal: openAccessDeniedModalReducer,
},
});
......@@ -44,6 +46,7 @@ export const {
openEditOverlayModal,
openLoggedInMenuModal,
openErrorReportModal,
openAccessDeniedModal,
} = modalSlice.actions;
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