Skip to content

Commit

Permalink
Merge pull request #2836 from florkbr/add-ip-range-input-to-manage-sat
Browse files Browse the repository at this point in the history
Add ip address whitelist UI to manage satellites page for itless
  • Loading branch information
florkbr authored Sep 12, 2024
2 parents 2eefbb1 + 42be2f7 commit 20d6a98
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 15 deletions.
1 change: 1 addition & 0 deletions config/webpack.plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const plugins = (dev = false, beta = false, restricted = false) => {
'./DownloadButton': resolve(__dirname, '../src/pdf/DownloadButton.tsx'),
'./LandingNavFavorites': resolve(__dirname, '../src/components/FavoriteServices/LandingNavFavorites.tsx'),
'./DashboardFavorites': resolve(__dirname, '../src/components/FavoriteServices/DashboardFavorites.tsx'),
'./SatelliteToken': resolve(__dirname, '../src/layouts/SatelliteToken.tsx'),
},
shared: [
{ react: { singleton: true, eager: true, requiredVersion: deps.react } },
Expand Down
10 changes: 5 additions & 5 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { Fragment, Suspense, useContext, useState } from 'react';
import ReactDOM from 'react-dom';
import { useFlag } from '@unleash/proxy-client-react';
import Tools from './Tools';
import UnAuthtedHeader from './UnAuthtedHeader';
import { MastheadBrand, MastheadContent, MastheadMain } from '@patternfly/react-core/dist/dynamic/components/Masthead';
Expand All @@ -15,7 +16,6 @@ import { DeepRequired } from 'utility-types';

import './Header.scss';
import { activationRequestURLs } from '../../utils/consts';
import { ITLess } from '../../utils/common';
import SearchInput from '../Search/SearchInput';
import AllServicesDropdown from '../AllServicesDropdown/AllServicesDropdown';
import Breadcrumbs, { Breadcrumbsprops } from '../Breadcrumbs/Breadcrumbs';
Expand All @@ -40,14 +40,14 @@ export const Header = ({ breadcrumbsProps }: { breadcrumbsProps?: Breadcrumbspro
const { user } = useContext(ChromeAuthContext) as DeepRequired<ChromeAuthContextValue>;
const search = new URLSearchParams(window.location.search).keys().next().value;
const isActivationPath = activationRequestURLs.includes(search);
const isITLessEnv = ITLess();
const { pathname } = useLocation();
const noBreadcrumb = !['/', '/allservices', '/favoritedservices'].includes(pathname);
const { md, lg } = useWindowWidth();
const [searchOpen, setSearchOpen] = useState(false);
const hideAllServices = (isOpen: boolean) => {
setSearchOpen(isOpen);
};
const isITLess = useFlag('platform.chrome.itless');

return (
<Fragment>
Expand All @@ -64,18 +64,18 @@ export const Header = ({ breadcrumbsProps }: { breadcrumbsProps?: Breadcrumbspro
</Toolbar>
</MastheadMain>
<MastheadContent className="pf-v5-u-mx-md pf-v5-u-mx-0-on-2xl">
{user?.identity?.org_id && !isITLessEnv && ReactDOM.createPortal(<FeedbackRoute />, document.body)}
{user?.identity?.org_id && !isITLess && ReactDOM.createPortal(<FeedbackRoute />, document.body)}
{user && isActivationPath && <Activation user={user} request={search} />}
<Toolbar isFullHeight>
<ToolbarContent>
<ToolbarGroup variant="filter-group">
{user && (
<ToolbarItem>
{!(!md && searchOpen) && <AllServicesDropdown />}
{isITLessEnv && user?.identity?.user?.is_org_admin && <SatelliteLink />}
{isITLess && user?.identity?.user?.is_org_admin && <SatelliteLink />}
</ToolbarItem>
)}
{user && !isITLessEnv && (
{user && !isITLess && (
<ToolbarItem className="pf-v5-m-hidden pf-v5-m-visible-on-xl">
<ContextSwitcher user={user} className="data-hj-suppress sentry-mask" />
</ToolbarItem>
Expand Down
2 changes: 0 additions & 2 deletions src/components/RootApp/ScalprumRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import chromeHistory from '../../utils/chromeHistory';
import DefaultLayout from '../../layouts/DefaultLayout';
import AllServices from '../../layouts/AllServices';
import FavoritedServices from '../../layouts/FavoritedServices';
import SatelliteToken from '../../layouts/SatelliteToken';
import historyListener from '../../utils/historyListener';
import SegmentContext from '../../analytics/SegmentContext';
import LoadingFallback from '../../utils/loading-fallback';
Expand Down Expand Up @@ -259,7 +258,6 @@ const ScalprumRoot = memo(
}
/>
)}
{ITLess() && <Route path="/insights/satellite" element={<SatelliteToken />} />}
<Route path="/security" element={<DefaultLayout {...props} />} />
<Route path="*" element={<DefaultLayout Sidebar={Navigation} {...props} />} />
</Routes>
Expand Down
259 changes: 259 additions & 0 deletions src/components/Satellite/IPWhitelistTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import React, { useEffect, useState } from 'react';
import { debounce } from 'lodash';
import {
ActionGroup,
Bullseye,
Button,
EmptyState,
EmptyStateBody,
EmptyStateHeader,
EmptyStateVariant,
Form,
FormGroup,
FormHelperText,
HelperText,
HelperTextItem,
Modal,
ModalVariant,
Text,
TextContent,
TextInput,
ValidatedOptions,
} from '@patternfly/react-core';
import { InnerScrollContainer, OuterScrollContainer, Table, TableText, TableVariant, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import { ExclamationCircleIcon } from '@patternfly/react-icons';
import axios from 'axios';
import SkeletonTable from '@redhat-cloud-services/frontend-components/SkeletonTable';

type IPBlock = {
ip_block: string;
org_id: string;
created_at: string;
};

const IPWhitelistTable: React.FC = () => {
const [allAddresses, setAllAddresses] = useState<IPBlock[]>([]);
const [loaded, setLoaded] = useState(false);
const [actionPending, setActionPending] = useState(false);
const [inputAddresses, setInputAddresses] = useState('');
const [inputAddressesValidated, setInputAddressesesValidated] = useState(false);
const [removeAddresses, setRemoveAddresses] = useState('');
const [isIPModalOpen, setIsIPModalOpen] = useState(false);
const [isIPRemoveModalOpen, setIsIPRemoveModalOpen] = useState(false);

const getIPAddresses = () => {
return axios.get('/api/mbop/v1/allowlist');
};

const removeIPAddresses = (ipBlock: string) => {
return axios.delete(`/api/mbop/v1/allowlist?block=${ipBlock}`);
};

const addIPAddresses = (ipBlock: string) => {
return axios.post('/api/mbop/v1/allowlist', { ip_block: ipBlock });
};

useEffect(() => {
if (!loaded && !actionPending) {
getIPAddresses()
.then((res) => {
setAllAddresses(res.data);
setLoaded(true);
})
.catch((err) => console.error(err));
}
}, [loaded, actionPending]);

const onChangedAddresses = (value: string) => {
setInputAddresses(value);
setInputAddressesesValidated(validateIPAddress(value));
};

const onSubmitAddresses = () => {
setActionPending(true);
addIPAddresses(inputAddresses)
.then(() => {
setInputAddresses('');
setIsIPModalOpen(false);
setLoaded(false);
return getIPAddresses();
})
.then((res) => {
setAllAddresses(res.data);
setLoaded(true);
})
.catch((err) => console.error(err))
.finally(() => setActionPending(false));
};

const onRemoveAddresses = () => {
setActionPending(true);
removeIPAddresses(removeAddresses)
.then(() => {
setRemoveAddresses('');
setIsIPRemoveModalOpen(false);
setLoaded(false);
return getIPAddresses();
})
.then((res) => {
setAllAddresses(res.data);
setLoaded(true);
})
.catch((err) => console.error(err))
.finally(() => setActionPending(false));
};

const onChangedAddressesDebounced = debounce(onChangedAddresses, 500);

const validateIPAddress = (address: string) => {
return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/([1-9]|[12][0-9]|3[0-2]))?$/.test(
address
);
};

const validationError = inputAddresses.length > 0 && !inputAddressesValidated;

const addIPModal = (
<Modal
isOpen={isIPModalOpen}
onClose={() => {
setInputAddresses('');
setIsIPModalOpen(false);
}}
title={'Add IP Addresses to Allow List'}
variant={ModalVariant.medium}
>
<Form onSubmit={(event: React.FormEvent<HTMLFormElement>) => event.preventDefault()}>
<FormGroup>
<TextContent>
<Text>Before connecting to your satellite servers, Red Hat needs to add your IP address or range of IP addresses to an allow-list.</Text>
</TextContent>
<TextInput
validated={validationError ? ValidatedOptions.error : ValidatedOptions.default}
placeholder="127.0.0.1/32"
onChange={(_event, value) => onChangedAddressesDebounced(value)}
></TextInput>
{validationError && (
<FormHelperText>
<HelperText>
<HelperTextItem icon={<ExclamationCircleIcon />} variant={ValidatedOptions.error}>
Enter a valid IP address or CIDR notation IP range
</HelperTextItem>
</HelperText>
</FormHelperText>
)}
</FormGroup>
<ActionGroup>
<Button isDisabled={inputAddresses.length <= 0 || validationError || actionPending} onClick={onSubmitAddresses}>
Submit
</Button>
</ActionGroup>
</Form>
</Modal>
);

const removeIPModal = (
<Modal
isOpen={isIPRemoveModalOpen}
onClose={() => {
setRemoveAddresses('');
setIsIPRemoveModalOpen(false);
}}
title={'Remove IP Addresses from Allow List'}
variant={ModalVariant.medium}
>
<Form onSubmit={(event: React.FormEvent<HTMLFormElement>) => event.preventDefault()}>
<FormGroup>
<TextContent>
<Text>The following IP addresses will be removed from the allow list</Text>
</TextContent>
<TextInput isDisabled value={removeAddresses}></TextInput>
</FormGroup>
<ActionGroup>
<Button onClick={onRemoveAddresses} isDisabled={actionPending} variant="danger">
Remove
</Button>
</ActionGroup>
</Form>
</Modal>
);

const columnNames = {
ip_block: 'IP Block',
org_id: 'Org ID',
created_at: 'Created At',
remove: '',
};

const skeletonTable = <SkeletonTable variant={TableVariant.compact} rows={9} columns={Object.values(columnNames)} />;

const emptyTable = (
<Tr style={{ border: 'none' }}>
<Td colSpan={8}>
<Bullseye>
<EmptyState variant={EmptyStateVariant.sm}>
<EmptyStateHeader titleText="No IP Addresses Allowed" headingLevel="h2" />
<EmptyStateBody>
Before connecting to your satellite servers, Red Hat needs to add your IP address or range of IP addresses to an allow-list.
</EmptyStateBody>
</EmptyState>
</Bullseye>
</Td>
</Tr>
);

const ipTable = (
<OuterScrollContainer style={{ maxHeight: '25rem' }}>
<InnerScrollContainer>
<Table aria-label="IP Address Allow List" variant={TableVariant.compact} isStickyHeader>
<Thead>
<Tr>
<Th>{columnNames.ip_block}</Th>
<Th>{columnNames.org_id}</Th>
<Th>{columnNames.created_at}</Th>
<Th>{columnNames.remove}</Th>
</Tr>
</Thead>
<Tbody>
{allAddresses.length <= 0 && emptyTable}
{allAddresses.map((ipBlock) => (
<Tr key={ipBlock.ip_block}>
<Td dataLabel={columnNames.ip_block}>{ipBlock.ip_block}</Td>
<Td dataLabel={columnNames.org_id}>{ipBlock.org_id}</Td>
<Td dataLabel={columnNames.created_at}>{ipBlock.created_at}</Td>
<Td dataLabel={columnNames.remove} modifier="fitContent">
<TableText>
<Button
variant="secondary"
onClick={() => {
setRemoveAddresses(ipBlock.ip_block);
setIsIPRemoveModalOpen(true);
}}
>
Remove
</Button>
</TableText>
</Td>
</Tr>
))}
</Tbody>
</Table>
</InnerScrollContainer>
</OuterScrollContainer>
);

return (
<>
{addIPModal}
{removeIPModal}
<>
{loaded ? ipTable : skeletonTable}
<div>
<Button onClick={() => setIsIPModalOpen(true)}>Add IP Addresses</Button>
</div>
</>
</>
);
};

export default IPWhitelistTable;
29 changes: 21 additions & 8 deletions src/layouts/SatelliteToken.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import React, { useEffect, useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import axios from 'axios';
import { Header } from '../components/Header/Header';
import { useFlag } from '@unleash/proxy-client-react';
import { Button } from '@patternfly/react-core/dist/dynamic/components/Button';
import { Card, CardBody, CardTitle } from '@patternfly/react-core/dist/dynamic/components/Card';
import { ClipboardCopy } from '@patternfly/react-core/dist/dynamic/components/ClipboardCopy';
import { List, ListComponent, ListItem, OrderType } from '@patternfly/react-core/dist/dynamic/components/List';
import { Masthead } from '@patternfly/react-core/dist/dynamic/components/Masthead';
import { Page, PageSection } from '@patternfly/react-core/dist/dynamic/components/Page';
import SatelliteTable from '../components/Satellite/SatelliteTable';
import IPWhitelistTable from '../components/Satellite/IPWhitelistTable';
import { getEnv } from '../utils/common';
import ChromeAuthContext from '../auth/ChromeAuthContext';
import NotFoundRoute from '../components/NotFoundRoute';

const SatelliteToken: React.FC = () => {
const [token, setToken] = useState('');
const [error, setError] = useState(null);
const { user } = useContext(ChromeAuthContext);
const isITLess = useFlag('platform.chrome.itless');

if (!isITLess) {
return <NotFoundRoute />;
}

const generateToken = () => {
axios
Expand All @@ -38,11 +46,6 @@ const SatelliteToken: React.FC = () => {
<Page
className="chr-c-all-services"
onPageResize={null} // required to disable PF resize observer that causes re-rendring issue
header={
<Masthead className="chr-c-masthead">
<Header />
</Masthead>
}
>
<PageSection padding={{ default: 'noPadding', md: 'padding', lg: 'padding' }}>
<Card>
Expand Down Expand Up @@ -87,6 +90,16 @@ const SatelliteToken: React.FC = () => {
</CardBody>
</Card>
</PageSection>
{user.identity.user?.is_org_admin ? (
<PageSection>
<Card>
<CardTitle>IP Address Allow List</CardTitle>
<CardBody>
<IPWhitelistTable />
</CardBody>
</Card>
</PageSection>
) : null}
</Page>
</div>
);
Expand Down

0 comments on commit 20d6a98

Please sign in to comment.