Skip to content

Commit

Permalink
Fix server side search (#1302)
Browse files Browse the repository at this point in the history
* First working iteration

* Replace connected private route with own privateRoute component

* Replace PrivateRoute component in fhir-app
  • Loading branch information
peterMuriuki authored Nov 30, 2023
1 parent e9b2c46 commit 2cc7d53
Show file tree
Hide file tree
Showing 8 changed files with 325 additions and 315 deletions.
6 changes: 5 additions & 1 deletion app/src/App/fhir-apps.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import React from 'react';
import { Resource404, PrivateComponent, PublicComponent } from '@opensrp/react-utils';
import {
Resource404,
PrivateRoute as PrivateComponent,
PublicComponent,
} from '@opensrp/react-utils';
import {
AuthorizationGrantType,
ConnectedOauthCallback,
Expand Down
1 change: 0 additions & 1 deletion packages/react-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
},
"dependencies": {
"@ant-design/icons": "^4.7.0",
"@onaio/connected-private-route": "^0.1.0",
"@onaio/connected-reducer-registry": "^0.0.3",
"@onaio/session-reducer": "^0.0.12",
"@onaio/utils": "^0.0.1",
Expand Down
77 changes: 77 additions & 0 deletions packages/react-utils/src/components/PrivateRoute/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { isAuthenticated } from '@onaio/session-reducer';
import React from 'react';
import { useSelector } from 'react-redux';
import { RouteProps, Route, Redirect, useRouteMatch, useLocation } from 'react-router';
import { getAllConfigs } from '@opensrp/pkg-config';
import { RbacCheck } from '@opensrp/rbac';
import { UnauthorizedPage } from '../UnauthorizedPage';

const configs = getAllConfigs();

export const LOGIN_REDIRECT_URL_PARAM = 'next';

/** interface for PrivateRoute props */
interface PrivateRouteProps extends RouteProps {
disableLoginProtection: boolean /** should we disable login protection */;
redirectPath: string /** redirect to this path is use if not authenticated */;
permissions: string[] /** string representing permissions required to view nested view */;
}

/** declare default props for PrivateRoute */
const defaultPrivateRouteProps: Partial<PrivateRouteProps> = {
disableLoginProtection: false,
redirectPath: '/login',
permissions: [],
};

/**
* Wrapper around route that makes sure user is authenticated and has the correct permission
*
* @param props - component props
*/
const PrivateRoute = (props: PrivateRouteProps) => {
const allProps = {
...props,
keycloakBaseURL: configs.keycloakBaseURL,
opensrpBaseURL: configs.opensrpBaseURL,
fhirBaseURL: configs.fhirBaseURL,
};
const { component, disableLoginProtection, redirectPath, permissions, ...routeProps } = allProps;
const Component = component as unknown as typeof React.Component;

const match = useRouteMatch();
const location = useLocation();
const authenticated = useSelector((state) => isAuthenticated(state));

const nextUrl = match.path;
const currentSParams = new URLSearchParams(location.search);
currentSParams.set(LOGIN_REDIRECT_URL_PARAM, nextUrl);

const fullRedirectPath = `${redirectPath}?${currentSParams.toString()}`;
const okToRender = authenticated === true || disableLoginProtection === true;
if (!okToRender) {
return <Redirect to={fullRedirectPath} />;
}

return (
<Route {...routeProps}>
{(routeProps) => (
<RbacCheck
permissions={permissions}
fallback={
<UnauthorizedPage
title={'403'}
errorMessage={'Sorry, you are not authorized to access this page'}
/>
}
>
<Component {...routeProps} {...allProps} />
</RbacCheck>
)}
</Route>
);
};

PrivateRoute.defaultProps = defaultPrivateRouteProps;

export { PrivateRoute };
239 changes: 239 additions & 0 deletions packages/react-utils/src/components/PrivateRoute/tests/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/naming-convention */
import React, { useEffect, useState } from 'react';
import { act } from 'react-dom/test-utils';
import { history } from '@onaio/connected-reducer-registry';
import { Provider } from 'react-redux';
import { MemoryRouter, Router } from 'react-router';
import { store } from '@opensrp/store';
import { mount } from 'enzyme';
import toJson from 'enzyme-to-json';
import { authenticateUser } from '@onaio/session-reducer';
import flushPromises from 'flush-promises';
import fetch from 'jest-fetch-mock';
import { RoleContext, UserRole } from '@opensrp/rbac';
import { render } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { superUserRole } from '../../../helpers/test-utils';
import { PrivateRoute as PrivateComponent } from '../';

it('First check that user is logged in before Rbac', async () => {
const MockComponent = () => {
return <p>I love oof!</p>;
};
const history = createMemoryHistory();
const props = {
component: MockComponent,
redirectPath: '/login',
disableLoginProtection: false,
};

render(
<Provider store={store}>
<Router history={history}>
<RoleContext.Provider value={superUserRole}>
<PrivateComponent {...props} component={MockComponent} permissions={[]} />
</RoleContext.Provider>
</Router>
</Provider>
);
await act(async () => {
await flushPromises();
});

// should redirect non-AuthN'd users to login
expect(history.location.pathname).toEqual('/login');
expect(history.location.search).toEqual('?next=%2F');
});

it('PrivateComponent Renders correctly', async () => {
store.dispatch(
authenticateUser(
true,
{
email: '[email protected]',
name: 'This Name',
username: 'tHat Part',
},
{
roles: ['ROLE_VIEW_KEYCLOAK_USERS'],
username: 'superset-user',
user_id: 'cab07278-c77b-4bc7-b154-bcbf01b7d35b',
}
)
);
const MockComponent = () => {
return <p>I love oof!</p>;
};
const props = {
exact: true,
redirectPath: '/login',
disableLoginProtection: false,
path: '/admin',
authenticated: true,
};

const wrapper = mount(
<Provider store={store}>
<MemoryRouter initialEntries={[{ pathname: `/admin`, hash: '', search: '', state: {} }]}>
<RoleContext.Provider value={superUserRole}>
<PrivateComponent {...props} component={MockComponent} permissions={[]} />
</RoleContext.Provider>
</MemoryRouter>
</Provider>
);
await act(async () => {
await flushPromises();
await flushPromises();
});
wrapper.update();
// test if isAuthorized is called
expect(wrapper.exists(MockComponent)).toBeTruthy();
wrapper.unmount();
});

it('Show Unauthorized Page if role does not have sufficient permissions', async () => {
const MockComponent = () => {
return <p>I love oof!</p>;
};
const props = {
exact: true,
redirectPath: '/login',
disableLoginProtection: false,
path: '/admin',
authenticated: true,
};
const wrapper = mount(
<Provider store={store}>
<MemoryRouter initialEntries={[{ pathname: `/admin`, hash: '', search: '', state: {} }]}>
<RoleContext.Provider value={new UserRole()}>
<PrivateComponent
{...props}
component={MockComponent}
permissions={['iam_user.create']}
/>
</RoleContext.Provider>
</MemoryRouter>
</Provider>
);
await act(async () => {
await flushPromises();
wrapper.update();
});
expect(wrapper.exists(MockComponent)).toBeFalsy();
// test if UnauthorizedPage is rendered
expect(wrapper.find('UnauthorizedPage').text()).toMatchInlineSnapshot(
`"403Sorry, you are not authorized to access this pageGo backGo home"`
);
expect(toJson(wrapper.find('UnauthorizedPage'))).toBeTruthy();
wrapper.unmount();
});

it('Updates state on route/pathname changes for same component', async () => {
store.dispatch(
authenticateUser(
true,
{
email: '[email protected]',
name: 'This Name',
username: 'tHat Part',
},
{
roles: ['ROLE_VIEW_KEYCLOAK_USERS'],
username: 'superset-user',
user_id: 'cab07278-c77b-4bc7-b154-bcbf01b7d35b',
}
)
);

// mock component with internal state change
const MockComponent = (props: {
match: {
params: {
id: string;
};
};
}) => {
// get user id from url
const {
match: {
params: { id },
},
} = props;
const [someState, setSomeState] = useState<{
firstName: string;
lastName: string;
}>({
firstName: '',
lastName: '',
});

// update state on fetch - internal data change on mount
useEffect(() => {
fetch(`http://example.com/users/${id}`)
.then((response) => response.json())
.then((data) => setSomeState(data))
.catch((err) => {
throw err;
});
}, [id]);

return <p className="mockClassName">{`${someState.firstName} ${someState.lastName}`}</p>;
};

// mock re-fetch on re-mount
fetch
.mockOnce(
JSON.stringify({
firstName: 'Anon',
lastName: 'Ops',
})
)
.mockOnce(
JSON.stringify({
firstName: 'Anon',
lastName: 'Central',
})
);

const props = {
exact: true,
redirectPath: '/login',
disableLoginProtection: false,
path: '/admin/users/:id',
authenticated: true,
};

// start with user with id 1
history.push('/admin/users/1');

const wrapper = mount(
<Provider store={store}>
<Router history={history}>
<RoleContext.Provider value={superUserRole}>
<PrivateComponent {...props} component={MockComponent} permissions={[]} />
</RoleContext.Provider>
</Router>
</Provider>
);

await act(async () => {
await flushPromises();
wrapper.update();
});

expect(wrapper.find('.mockClassName').text()).toMatchInlineSnapshot(`"Anon Ops"`);

// simulate navigation - similar url but different pathname (id)
history.push('/admin/users/2');

await act(async () => {
await flushPromises();
wrapper.update();
});

// expect change to result of second fetch
expect(wrapper.find('.mockClassName').text()).toMatchInlineSnapshot(`"Anon Central"`);

wrapper.unmount();
});
45 changes: 0 additions & 45 deletions packages/react-utils/src/helpers/componentUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
import { Route, RouteProps, RouteComponentProps, useLocation } from 'react-router';
import { getAllConfigs } from '@opensrp/pkg-config';
import ConnectedPrivateRoute from '@onaio/connected-private-route';
import { UnauthorizedPage } from '../components/UnauthorizedPage';
import { useTranslation } from '../mls';
import { RbacCheck } from '@opensrp/rbac';

const configs = getAllConfigs();

/** Private/Public component props */
interface ComponentProps extends Partial<RouteProps> {
Expand All @@ -21,44 +14,6 @@ interface ComponentProps extends Partial<RouteProps> {
permissions: string[];
}

/**
* Util wrapper around ConnectedPrivateRoute to render components
* that use private routes/ require authentication
*
* @param props - Component props object
*/

export const PrivateComponent = (props: ComponentProps) => {
// props to pass on to Connected Private Route
const { permissions, component: WrappedComponent, ...otherProps } = props;

const { t } = useTranslation();

const RbacWrappedComponent = (props: Record<string, unknown>) => (
<RbacCheck
permissions={permissions}
fallback={
<UnauthorizedPage
title={t('403')}
errorMessage={t('Sorry, you are not authorized to access this page')}
/>
}
>
<WrappedComponent {...props} />
</RbacCheck>
);

const CPRProps = {
...otherProps,
component: RbacWrappedComponent,
keycloakBaseURL: configs.keycloakBaseURL,
opensrpBaseURL: configs.opensrpBaseURL,
fhirBaseURL: configs.fhirBaseURL,
};

return <ConnectedPrivateRoute {...CPRProps} />;
};

/**
* Util wrapper around Route for rendering components
* that use public routes/ dont require authentication
Expand Down
Loading

0 comments on commit 2cc7d53

Please sign in to comment.