diff --git a/api/v4/source/cloud.yaml b/api/v4/source/cloud.yaml index 52f4e619e042..71f03de08bb3 100644 --- a/api/v4/source/cloud.yaml +++ b/api/v4/source/cloud.yaml @@ -267,6 +267,36 @@ $ref: "#/components/responses/Forbidden" "501": $ref: "#/components/responses/NotImplemented" + /api/v4/cloud/installation: + get: + tags: + - cloud + summary: GET endpoint for Installation information + description: > + An endpoint for fetching the installation information. + + ##### Permissions + + Must have `sysconsole_read_site_ip_filters` permission and be licensed for Cloud. + + __Minimum server version__: 9.1 + __Note:__ This is intended for internal use and is subject to change. + operationId: GetEndpointForInstallationInformation + responses: + "200": + description: Installation returned successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Installation" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "501": + $ref: "#/components/responses/NotImplemented" /api/v4/cloud/subscription/invoices: get: tags: diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 8b44bc6eb788..5679a878e2cb 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3524,6 +3524,17 @@ components: Description: description: A description for the CIDRBlock type: string + Installation: + type: object + properties: + id: + description: A unique identifier + type: string + allowed_ip_ranges: + $ref: "#/components/schemas/AllowedIPRange" + state: + description: The current state of the installation + type: string externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index 626679327659..f0590494d696 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -54,6 +54,9 @@ func (api *API) InitCloud() { // POST /api/v4/cloud/webhook api.BaseRoutes.Cloud.Handle("/webhook", api.CloudAPIKeyRequired(handleCWSWebhook)).Methods("POST") + // GET /api/v4/cloud/installation + api.BaseRoutes.Cloud.Handle("/installation", api.APISessionRequired(getInstallation)).Methods("GET") + // GET /api/v4/cloud/cws-health-check api.BaseRoutes.Cloud.Handle("/check-cws-connection", api.APIHandler(handleCheckCWSConnection)).Methods("GET") @@ -474,6 +477,29 @@ func getCloudCustomer(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(json) } +func getInstallation(c *Context, w http.ResponseWriter, r *http.Request) { + ensured := ensureCloudInterface(c, "Api4.getInstallation") + if !ensured { + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadIPFilters) { + c.SetPermissionError(model.PermissionSysconsoleReadIPFilters) + return + } + + installation, err := c.App.Cloud().GetInstallation(c.AppContext.Session().UserId) + if err != nil { + c.Err = model.NewAppError("Api4.getInstallation", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + if err := json.NewEncoder(w).Encode(installation); err != nil { + c.Err = model.NewAppError("Api4.getInstallation", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } +} + // getLicenseSelfServeStatus makes check for the license in the CWS self-serve portal and establishes if the license is renewable, expandable etc. func getLicenseSelfServeStatus(c *Context, w http.ResponseWriter, r *http.Request) { ensured := ensureCloudInterface(c, "Api4.getLicenseSelfServeStatus") diff --git a/server/einterfaces/cloud.go b/server/einterfaces/cloud.go index c447a611c83b..be77852f0e6f 100644 --- a/server/einterfaces/cloud.go +++ b/server/einterfaces/cloud.go @@ -56,4 +56,5 @@ type CloudInterface interface { ApplyIPFilters(userID string, ranges *model.AllowedIPRanges) (*model.AllowedIPRanges, error) GetIPFilters(userID string) (*model.AllowedIPRanges, error) + GetInstallation(userID string) (*model.Installation, error) } diff --git a/server/einterfaces/mocks/CloudInterface.go b/server/einterfaces/mocks/CloudInterface.go index 86d7b2708e5e..96bced70e366 100644 --- a/server/einterfaces/mocks/CloudInterface.go +++ b/server/einterfaces/mocks/CloudInterface.go @@ -395,6 +395,32 @@ func (_m *CloudInterface) GetIPFilters(userID string) (*model.AllowedIPRanges, e return r0, r1 } +// GetInstallation provides a mock function with given fields: userID +func (_m *CloudInterface) GetInstallation(userID string) (*model.Installation, error) { + ret := _m.Called(userID) + + var r0 *model.Installation + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.Installation, error)); ok { + return rf(userID) + } + if rf, ok := ret.Get(0).(func(string) *model.Installation); ok { + r0 = rf(userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Installation) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetInvoicePDF provides a mock function with given fields: userID, invoiceID func (_m *CloudInterface) GetInvoicePDF(userID string, invoiceID string) ([]byte, string, error) { ret := _m.Called(userID, invoiceID) diff --git a/server/public/model/cloud.go b/server/public/model/cloud.go index b81bf91f038d..737b26baf5de 100644 --- a/server/public/model/cloud.go +++ b/server/public/model/cloud.go @@ -301,6 +301,12 @@ type CreateSubscriptionRequest struct { DiscountID string `json:"discount_id"` } +type Installation struct { + ID string `json:"id"` + State string `json:"state"` + AllowedIPRanges *AllowedIPRanges `json:"allowed_ip_ranges"` +} + type Feedback struct { Reason string `json:"reason"` Comments string `json:"comments"` diff --git a/server/templates/ip_filters_changed.html b/server/templates/ip_filters_changed.html index af27433b86f2..7d71323c55f3 100644 --- a/server/templates/ip_filters_changed.html +++ b/server/templates/ip_filters_changed.html @@ -367,7 +367,7 @@ - + @@ -441,7 +441,7 @@ - + diff --git a/server/templates/ip_filters_changed.mjml b/server/templates/ip_filters_changed.mjml index e392a59e8ed7..006942d1f0a5 100644 --- a/server/templates/ip_filters_changed.mjml +++ b/server/templates/ip_filters_changed.mjml @@ -4,32 +4,35 @@ - + + + + + - + - {{.Props.TroubleAccessingTitle}} + {{.Props.TroubleAccessingTitle}} {{if .Props.ActorEmail}} {{.Props.SendAnEmailTo}} - + {{end}} {{ if .Props.LogInToCustomerPortal}} - + {{.Props.LogInToCustomerPortal}} - + {{end}} - + {{.Props.ContactSupport}} diff --git a/webapp/channels/src/actions/cloud.tsx b/webapp/channels/src/actions/cloud.tsx index 79489ce5f99b..671f1b11d581 100644 --- a/webapp/channels/src/actions/cloud.tsx +++ b/webapp/channels/src/actions/cloud.tsx @@ -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(), diff --git a/webapp/channels/src/components/admin_console/ip_filtering/index.tsx b/webapp/channels/src/components/admin_console/ip_filtering/index.tsx index 1669af592ae8..96473cfcab78 100644 --- a/webapp/channels/src/components/admin_console/ip_filtering/index.tsx +++ b/webapp/channels/src/components/admin_console/ip_filtering/index.tsx @@ -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(); const {formatMessage} = useIntl(); const [ipFilters, setIpFilters] = useState(null); const [originalIpFilters, setOriginalIpFilters] = useState(null); @@ -35,8 +38,27 @@ const IPFiltering = () => { const [currentUsersIP, setCurrentUsersIP] = useState(null); const [saving, setSaving] = useState(false); const [filterToggle, setFilterToggle] = useState(false); + const [installationStatus, setInstallationStatus] = useState(''); + + // savingMessage allows the component to change the label on the Save button in the SaveChangesPanel + const [savingMessage, setSavingMessage] = useState(''); + + // 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(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(( + <> + {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(( +
+ {text} +
+ ), + ); + } + 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 = () => { diff --git a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss index 8f2ea0bc9eee..e75a056938a1 100644 --- a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss +++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss @@ -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; + } } } diff --git a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx index bdb31c818367..e33c26bddb3b 100644 --- a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx +++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx @@ -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()); + + 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()); + + 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(); + }); + }); }); diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx index ca849fed433e..32ab0a31428c 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx @@ -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 (
{ cancelLink !== '' && diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 068892ee948a..9afc9f3e6a41 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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. Learn more in the docs", "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 Customer Portal 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? All IP addresses will have access to the workspace.", "admin.ip_filtering.update_filter": "Update filter", "admin.ip_filtering.yes_disable_ip_filtering": "Yes, disable IP Filtering", diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 2ff355009aa8..c9b84a862f5c 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -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( + `${this.getCloudRoute()}/installation`, + {method: 'get'}, + ); + } + getRenewalLink = () => { return this.doFetch<{renewal_link: string}>( `${this.getBaseRoute()}/license/renewal`, diff --git a/webapp/platform/types/src/cloud.ts b/webapp/platform/types/src/cloud.ts index 9cdb28a01c95..79811a55257f 100644 --- a/webapp/platform/types/src/cloud.ts +++ b/webapp/platform/types/src/cloud.ts @@ -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;