[CLD-6678] Various improvements for IP filtering feature (#25485)

* Add GetInstallation function, allow IP Filtering page to fetch installation state, other fixes for IP filter feature

* Fix pipelines

* Run make build-templates

* Fixing i18n

* Fix openapi docs

* Fix openapi docs again

* make build-templates

* Update test to ensure that spinner is removed after installation becomes stable

* Fix types, style

* update openapi because I can't validate locally...

* Updates according to Matt's feedback

* Add a limit to number of times installation is requested before an error is displayed

* Make button disable immediately

* Updates based on PR feedback

* A couple missed occurrences of whitespace

* Grammar fix in failed to fetch error

---------

Co-authored-by: Gabe Jackson <3694686+gabrieljackson@users.noreply.github.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Nick Misasi
2023-11-28 09:09:50 -05:00
коммит произвёл GitHub
родитель 894bba81d8
Коммит 95670abcea
16 изменённых файлов: 318 добавлений и 21 удалений

Просмотреть файл

@@ -84,6 +84,17 @@ export function completeStripeAddPaymentMethod(
};
}
export function getInstallation() {
return async () => {
try {
const installation = await Client4.getInstallation();
return {data: installation};
} catch (e: any) {
return {error: e.message};
}
};
}
export function subscribeCloudSubscription(
productId: string,
shippingAddress: Address = getBlankAddressWithCountry(),

Просмотреть файл

@@ -8,7 +8,10 @@ import {useDispatch} from 'react-redux';
import {AlertOutlineIcon} from '@mattermost/compass-icons/components';
import type {AllowedIPRange, FetchIPResponse} from '@mattermost/types/config';
import type {DispatchFunc} from 'mattermost-redux/types/actions';
import {applyIPFilters, getCurrentIP, getIPFilters} from 'actions/admin_actions';
import {getInstallation} from 'actions/cloud';
import {closeModal, openModal} from 'actions/views/modals';
import AdminHeader from 'components/widgets/admin_console/admin_header';
@@ -27,7 +30,7 @@ import SaveChangesPanel from '../team_channel_settings/save_changes_panel';
import './ip_filtering.scss';
const IPFiltering = () => {
const dispatch = useDispatch();
const dispatch = useDispatch<DispatchFunc>();
const {formatMessage} = useIntl();
const [ipFilters, setIpFilters] = useState<AllowedIPRange[] | null>(null);
const [originalIpFilters, setOriginalIpFilters] = useState<AllowedIPRange[] | null>(null);
@@ -35,8 +38,27 @@ const IPFiltering = () => {
const [currentUsersIP, setCurrentUsersIP] = useState<string | null>(null);
const [saving, setSaving] = useState<boolean>(false);
const [filterToggle, setFilterToggle] = useState<boolean>(false);
const [installationStatus, setInstallationStatus] = useState<string>('');
// savingMessage allows the component to change the label on the Save button in the SaveChangesPanel
const [savingMessage, setSavingMessage] = useState<string>('');
// savingDescription is a JSX element that will be displayed in the serverError bar on the SaveChangesPanel. This allows us to provide more information on loading while previous changes are applied
const [savingDescription, setSavingDescription] = useState<JSX.Element | null>(null);
const savingButtonMessages = {
SAVING_PREVIOUS_CHANGE: formatMessage({id: 'admin.ip_filtering.saving_previous_change', defaultMessage: 'Other changes being applied...'}),
SAVING_CHANGES: formatMessage({id: 'admin.ip_filtering.saving_changes', defaultMessage: 'Applying changes...'}),
};
const savingDescriptionMessages = {
SAVING_PREVIOUS_CHANGE: formatMessage({id: 'admin.ip_filtering.saving_previous_change_description', defaultMessage: 'Please wait while changes from another admin are applied.'}),
SAVING_CHANGES: formatMessage({id: 'admin.ip_filtering.saving_changes_description', defaultMessage: 'Please wait while your changes are applied.'}),
};
useEffect(() => {
getInstallationStatus();
getIPFilters((data: AllowedIPRange[]) => {
setIpFilters(data);
setOriginalIpFilters(data);
@@ -57,7 +79,7 @@ const IPFiltering = () => {
setSaveNeeded(haveFiltersChanged);
}, [ipFilters, originalIpFilters]);
const currentIPIsInRange = () => {
const currentIPIsInRange = (): boolean => {
if (!filterToggle) {
return true;
}
@@ -92,6 +114,61 @@ const IPFiltering = () => {
}
}, [filterToggle]);
function pollInstallationStatus() {
let installationFetchAttempts = 0;
const interval = setInterval(async () => {
if (installationFetchAttempts > 15) {
// Average time for provisioner to update is around 30 seconds. This allows up to 75 seconds before it will stop fetching, displaying an error
setSavingDescription((
<>
<AlertOutlineIcon size={16}/> {formatMessage({id: 'admin.ip_filtering.failed_to_fetch_installation_state', defaultMessage: 'Failed to fetch your workspace\'s status. Please try again later or contact support.'})}
</>
));
clearInterval(interval);
return;
}
const result = await dispatch(getInstallation());
installationFetchAttempts++;
if (result.data) {
const {data} = result;
if (data.state === 'stable') {
setSaving(false);
setSavingDescription(null);
clearInterval(interval);
}
setInstallationStatus(data.state);
}
}, 5000);
}
async function getInstallationStatus() {
const result = await dispatch(getInstallation());
if (result.data) {
const {data} = result;
setInstallationStatus(data.state);
if (installationStatus === '' && data.state !== 'stable') {
// This is the first load of the page, and the installation is not stable, so we must lock saving until it becomes stable
setSaving(true);
// Override the default messages for the save button and the error message to be communicative of the current state to the user
setSavingMessage(savingButtonMessages.SAVING_PREVIOUS_CHANGE);
changeSavingDescription(savingDescriptionMessages.SAVING_PREVIOUS_CHANGE);
}
if (data.state !== 'stable') {
pollInstallationStatus();
}
}
}
function changeSavingDescription(text: string) {
setSavingDescription((
<div className='saving-message-description'>
{text}
</div>
),
);
}
function handleEditFilter(filter: AllowedIPRange, existingRange?: AllowedIPRange) {
setIpFilters((prevIpFilters) => {
if (!prevIpFilters) {
@@ -155,13 +232,16 @@ const IPFiltering = () => {
}
function handleSave() {
setInstallationStatus('update-requested');
setSaving(true);
setSavingMessage(savingButtonMessages.SAVING_CHANGES);
changeSavingDescription(savingDescriptionMessages.SAVING_CHANGES);
dispatch(closeModal(ModalIdentifiers.IP_FILTERING_SAVE_CONFIRMATION_MODAL));
const success = (data: AllowedIPRange[]) => {
setIpFilters(data);
setSaving(false);
setSaveNeeded(false);
setOriginalIpFilters(data);
getInstallationStatus();
};
applyIPFilters(ipFilters ?? [], success);
@@ -220,6 +300,10 @@ const IPFiltering = () => {
}
const saveBarError = () => {
if (savingDescription !== null) {
return savingDescription;
}
if (currentIPIsInRange()) {
return undefined;
}
@@ -256,10 +340,11 @@ const IPFiltering = () => {
</div>
<SaveChangesPanel
saving={saving}
saveNeeded={saveNeeded}
isDisabled={!currentIPIsInRange}
saveNeeded={saveNeeded || installationStatus !== 'stable'}
isDisabled={!currentIPIsInRange() || installationStatus !== 'stable'}
onClick={handleSaveClick}
serverError={saveBarError()}
savingMessage={savingMessage}
cancelLink=''
/>
</div>

Просмотреть файл

@@ -1,6 +1,12 @@
.IPFiltering {
height: 100%;
.admin-console-save {
.btn.btn-primary:disabled {
color: rgba(var(--center-channel-color-rgb), 0.32) !important;
}
}
.MainPanel {
display: flex;
height: 100%;
@@ -78,5 +84,10 @@
margin-right: 7px;
margin-left: 7px;
}
.saving-message-description {
margin-left: 16px;
color: rgba(var(--center-channel-color-rgb), 0.72) !important;
}
}
}

Просмотреть файл

@@ -7,6 +7,7 @@ import {IntlProvider} from 'react-intl';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {Installation} from '@mattermost/types/cloud';
import type {AllowedIPRange, FetchIPResponse} from '@mattermost/types/config';
import {Client4} from 'mattermost-redux/client';
@@ -36,11 +37,13 @@ describe('IPFiltering', () => {
const applyIPFiltersMock = jest.fn(() => Promise.resolve(ipFilters));
const getIPFiltersMock = jest.fn(() => Promise.resolve(ipFilters));
const getCurrentIPMock = jest.fn(() => Promise.resolve({ip: currentIP} as FetchIPResponse));
const getInstallationMock = jest.fn(() => Promise.resolve({id: 'abc123', state: 'stable'} as Installation));
beforeEach(() => {
Client4.applyIPFilters = applyIPFiltersMock;
Client4.getIPFilters = getIPFiltersMock;
Client4.getCurrentIP = getCurrentIPMock;
Client4.getInstallation = getInstallationMock;
});
const mockedStore = configureStore({
@@ -201,4 +204,68 @@ describe('IPFiltering', () => {
expect(applyIPFiltersMock).toHaveBeenCalledTimes(1);
});
});
test('Save button is disabled when users IP is not within the allowed ranges', async () => {
const {getByLabelText, getByText, queryByText, getByTestId} = render(wrapWithIntlProviderAndStore(<IPFiltering/>));
await waitFor(() => {
expect(getByText('Test IP Filter')).toBeInTheDocument();
});
fireEvent.mouseEnter(screen.getByText('Test IP Filter'));
fireEvent.click(screen.getByRole('button', {
name: /Edit/i,
}));
const descriptionInput = getByLabelText('Enter a name for this rule');
const cidrInput = getByLabelText('Enter IP Range');
const saveButton = screen.getByTestId('save-add-edit-button');
fireEvent.change(cidrInput, {target: {value: '192.168.0.0/16'}});
fireEvent.change(descriptionInput, {target: {value: 'zzzzzfilter'}});
fireEvent.click(saveButton);
await waitFor(() => {
expect(getByText('zzzzzfilter')).toBeInTheDocument();
expect(getByText('192.168.0.0/16')).toBeInTheDocument();
// ensure that the old description is gone, because we've now changed it
expect(queryByText('Test IP Filter')).toBeNull();
expect(getByTestId('saveSetting')).toBeDisabled();
});
});
test('Save button is disabled with a spinner when the page is loaded with a not-stable installation', async () => {
const getInstallationNotStableMock = jest.fn(() => Promise.resolve({id: 'abc123', state: 'update-in-progress'} as Installation));
Client4.getInstallation = getInstallationNotStableMock;
jest.useFakeTimers();
const {getByText, queryByText} = render(wrapWithIntlProviderAndStore(<IPFiltering/>));
await waitFor(() => {
expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument();
expect(screen.getByRole('button', {pressed: true})).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('filterToggle-button'));
await waitFor(() => {
expect(screen.getByRole('button', {pressed: false})).toBeInTheDocument();
});
await waitFor(() => {
expect(queryByText('Test IP Filter')).not.toBeInTheDocument();
});
expect(getByText('Other changes being applied...')).toBeInTheDocument();
expect(getByText('Other changes being applied...').closest('button')).toBeDisabled();
// Adjust mock so it now returns a stable state
Client4.getInstallation = getInstallationMock;
jest.advanceTimersByTime(5100);
await waitFor(() => {
expect(getByText('Save')).toBeInTheDocument();
});
});
});

Просмотреть файл

@@ -2,13 +2,11 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import BlockableLink from 'components/admin_console/blockable_link';
import SaveButton from 'components/save_button';
import {localizeMessage} from 'utils/utils';
type Props = {
saving: boolean;
saveNeeded: boolean;
@@ -16,16 +14,18 @@ type Props = {
cancelLink: string;
serverError?: JSX.Element;
isDisabled?: boolean;
savingMessage?: string;
};
const SaveChangesPanel = ({saveNeeded, onClick, saving, serverError, cancelLink, isDisabled}: Props) => {
const SaveChangesPanel = ({saveNeeded, onClick, saving, serverError, cancelLink, isDisabled, savingMessage}: Props) => {
const {formatMessage} = useIntl();
return (
<div className='admin-console-save'>
<SaveButton
saving={saving}
disabled={isDisabled || !saveNeeded}
onClick={onClick}
savingMessage={localizeMessage('admin.team_channel_settings.saving', 'Saving Config...')}
savingMessage={savingMessage ?? formatMessage({id: 'admin.team_channel_settings.saving', defaultMessage: 'Saving Config...'})}
/>
{
cancelLink !== '' &&

Просмотреть файл

@@ -1204,6 +1204,7 @@
"admin.ip_filtering.enable_ip_filtering": "Enable IP Filtering",
"admin.ip_filtering.enable_ip_filtering_description": "Limit access to your workspace by IP address. <learnmore>Learn more in the docs</learnmore>",
"admin.ip_filtering.error_on_page": "Your IP address is not included in your filters",
"admin.ip_filtering.failed_to_fetch_installation_state": "Failed to fetch your workspace's status. Please try again later or contact support.",
"admin.ip_filtering.filter_name": "Filter Name",
"admin.ip_filtering.include_your_ip": "Include your IP address in at least one of the rules below to continue.",
"admin.ip_filtering.ip_address_range": "IP Address Range",
@@ -1216,6 +1217,10 @@
"admin.ip_filtering.save": "Save",
"admin.ip_filtering.save_disclaimer_subtitle": "If you happen to block yourself with these settings, your workspace owner can log in to the <customerportal>Customer Portal</customerportal> to disable IP filtering to restore access.",
"admin.ip_filtering.save_disclaimer_title": "Using the Customer Portal to restore access",
"admin.ip_filtering.saving_changes": "Applying changes...",
"admin.ip_filtering.saving_changes_description": "Please wait while your changes are applied.",
"admin.ip_filtering.saving_previous_change": "Other changes being applied...",
"admin.ip_filtering.saving_previous_change_description": "Please wait while changes from another admin are applied.",
"admin.ip_filtering.turn_off_ip_filtering": "Are you sure you want to turn off IP Filtering? <strong>All IP addresses will have access to the workspace.</strong>",
"admin.ip_filtering.update_filter": "Update filter",
"admin.ip_filtering.yes_disable_ip_filtering": "Yes, disable IP Filtering",

Просмотреть файл

@@ -27,6 +27,7 @@ import {
Feedback,
WorkspaceDeletionRequest,
NewsletterRequestBody,
Installation,
} from '@mattermost/types/cloud';
import {
SelfHostedSignupForm,
@@ -3916,6 +3917,13 @@ export default class Client4 {
);
}
getInstallation = () => {
return this.doFetch<Installation>(
`${this.getCloudRoute()}/installation`,
{method: 'get'},
);
}
getRenewalLink = () => {
return this.doFetch<{renewal_link: string}>(
`${this.getBaseRoute()}/license/renewal`,

Просмотреть файл

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AllowedIPRange} from './config';
import {ValueOf} from './utilities';
export type CloudState = {
@@ -26,6 +27,12 @@ export type CloudState = {
};
}
export type Installation = {
id: string;
state: string;
allowed_ip_ranges: AllowedIPRange[];
}
export type Subscription = {
id: string;
customer_id: string;