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;