diff --git a/api/Makefile b/api/Makefile
index 387da3e31a..6660b91935 100644
--- a/api/Makefile
+++ b/api/Makefile
@@ -49,6 +49,7 @@ build-v4: node_modules playbooks
@cat $(V4_SRC)/permissions.yaml >> $(V4_YAML)
@cat $(V4_SRC)/imports.yaml >> $(V4_YAML)
@cat $(V4_SRC)/exports.yaml >> $(V4_YAML)
+ @cat $(V4_SRC)/ip_filters.yaml >> $(V4_YAML)
@if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi
@if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi
@echo Extracting code samples
diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml
index e36868ba63..8b44bc6eb7 100644
--- a/api/v4/source/definitions.yaml
+++ b/api/v4/source/definitions.yaml
@@ -3515,6 +3515,15 @@ components:
description: The time in milliseconds in which this acknowledgement was made.
type: integer
format: int64
+ AllowedIPRange:
+ type: object
+ properties:
+ CIDRBlock:
+ description: An IP address range in CIDR notation
+ type: string
+ Description:
+ description: A description for the CIDRBlock
+ type: string
externalDocs:
description: Find out more about Mattermost
url: 'https://about.mattermost.com'
diff --git a/api/v4/source/ip_filters.yaml b/api/v4/source/ip_filters.yaml
new file mode 100644
index 0000000000..4e87bfcc7c
--- /dev/null
+++ b/api/v4/source/ip_filters.yaml
@@ -0,0 +1,92 @@
+ /api/v4/ip_filtering:
+ get:
+ tags:
+ - ip
+ - filtering
+ summary: Get all IP filters
+ description: >
+ Retrieve a list of IP filters applied to the workspace
+
+ __Minimum server version__: 9.1
+ __Note:__ This is intended for internal use and only applicable to Cloud workspaces
+ operationId: GetIPFilters
+ responses:
+ "200":
+ description: IP Filters returned successfully
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/AllowedIPRange"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
+ "501":
+ $ref: "#/components/responses/NotImplemented"
+ post:
+ tags:
+ - ip
+ - filtering
+ summary: Get all IP filters
+ description: >
+ Adjust IP Filters applied to the workspace
+
+ __Minimum server version__: 9.1
+ __Note:__ This is intended for internal use and only applicable to Cloud workspaces
+ operationId: ApplyIPFilters
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/AllowedIPRange"
+ description: IP Filters to apply
+ required: true
+ responses:
+ "200":
+ description: IP Filters returned successfully
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/AllowedIPRange"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
+ "501":
+ $ref: "#/components/responses/NotImplemented"
+ /api/v4/ip_filtering/my_ip:
+ get:
+ tags:
+ - ip
+ - filtering
+ summary: Get all IP filters
+ description: >
+ Retrieve your current IP address as seen by the workspace
+
+ __Minimum server version__: 9.1
+ __Note:__ This is intended for internal use and only applicable to Cloud workspaces
+ operationId: MyIP
+ responses:
+ "200":
+ description: IP address returned successfully
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ ip:
+ type: string
+ description: Your current IP address
+ example: "192.168.0.1"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
+ "501":
+ $ref: "#/components/responses/NotImplemented"
diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go
index 5275df3b4b..c6a6957981 100644
--- a/server/channels/api4/api.go
+++ b/server/channels/api4/api.go
@@ -137,6 +137,8 @@ type Routes struct {
HostedCustomer *mux.Router // 'api/v4/hosted_customer'
Drafts *mux.Router // 'api/v4/drafts'
+
+ IPFiltering *mux.Router // 'api/v4/ip_filtering'
}
type API struct {
@@ -261,6 +263,8 @@ func Init(srv *app.Server) (*API, error) {
api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter()
+ api.BaseRoutes.IPFiltering = api.BaseRoutes.APIRoot.PathPrefix("/ip_filtering").Subrouter()
+
api.InitUser()
api.InitBot()
api.InitTeam()
@@ -304,6 +308,7 @@ func Init(srv *app.Server) (*API, error) {
api.InitUsage()
api.InitHostedCustomer()
api.InitDrafts()
+ api.InitIPFiltering()
srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404))
diff --git a/server/channels/api4/ip_filtering.go b/server/channels/api4/ip_filtering.go
new file mode 100644
index 0000000000..4ab48a5907
--- /dev/null
+++ b/server/channels/api4/ip_filtering.go
@@ -0,0 +1,139 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package api4
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/shared/mlog"
+ "github.com/mattermost/mattermost/server/v8/channels/app"
+ "github.com/mattermost/mattermost/server/v8/channels/audit"
+ "github.com/mattermost/mattermost/server/v8/einterfaces"
+)
+
+func (api *API) InitIPFiltering() {
+ api.BaseRoutes.IPFiltering.Handle("", api.APISessionRequired(getIPFilters)).Methods("GET")
+ api.BaseRoutes.IPFiltering.Handle("", api.APISessionRequired(applyIPFilters)).Methods("POST")
+ api.BaseRoutes.IPFiltering.Handle("/my_ip", api.APISessionRequired(myIP)).Methods("GET")
+}
+
+func ensureIPFilteringInterface(c *Context, where string) (einterfaces.IPFilteringInterface, bool) {
+ if c.App.IPFiltering() == nil || !c.App.Config().FeatureFlags.CloudIPFiltering || c.App.License() == nil || c.App.License().SkuShortName != model.LicenseShortSkuEnterprise {
+ c.Err = model.NewAppError(where, "api.context.ip_filtering.not_available.app_error", nil, "", http.StatusNotImplemented)
+ return nil, false
+ }
+ return c.App.IPFiltering(), true
+}
+
+func getIPFilters(c *Context, w http.ResponseWriter, r *http.Request) {
+ ipFiltering, ok := ensureIPFilteringInterface(c, "getIPFilters")
+ if !ok {
+ return
+ }
+
+ if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadIPFilters) {
+ c.SetPermissionError(model.PermissionSysconsoleReadIPFilters)
+ return
+ }
+
+ allowedRanges, err := ipFiltering.GetIPFilters()
+ if err != nil {
+ c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ if err := json.NewEncoder(w).Encode(allowedRanges); err != nil {
+ c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func applyIPFilters(c *Context, w http.ResponseWriter, r *http.Request) {
+ ipFiltering, ok := ensureIPFilteringInterface(c, "applyIPFilters")
+ if !ok {
+ return
+ }
+
+ if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteIPFilters) {
+ c.SetPermissionError(model.PermissionSysconsoleWriteIPFilters)
+ return
+ }
+
+ auditRec := c.MakeAuditRecord("applyIPFilters", audit.Fail)
+ defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
+
+ allowedRanges := &model.AllowedIPRanges{} // Initialize the allowedRanges variable
+ if err := json.NewDecoder(r.Body).Decode(allowedRanges); err != nil {
+ c.Err = model.NewAppError("applyIPFilters", "api.context.ip_filtering.apply_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ audit.AddEventParameterAuditable(auditRec, "IPFilter", allowedRanges)
+
+ updatedAllowedRanges, err := ipFiltering.ApplyIPFilters(allowedRanges)
+
+ if err != nil {
+ c.Err = model.NewAppError("applyIPFilters", "api.context.ip_filtering.apply_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ auditRec.Success()
+
+ c.App.Srv().Go(func() {
+ initiatingUser, err := c.App.Srv().Store().User().GetProfileByIds(context.Background(), []string{c.AppContext.Session().UserId}, nil, true)
+ if err != nil {
+ mlog.Error("Failed to get initiating user", mlog.Err(err))
+ }
+
+ users, err := c.App.Srv().Store().User().GetSystemAdminProfiles()
+ if err != nil {
+ mlog.Error("Failed to get system admins", mlog.Err(err))
+ }
+
+ cloudWorkspaceOwnerEmailAddress := ""
+ if c.App.License().IsCloud() {
+ portalUserCustomer, cErr := c.App.Cloud().GetCloudCustomer(c.AppContext.Session().UserId)
+ if cErr != nil {
+ mlog.Error("Failed to get portal user customer", mlog.Err(cErr))
+ }
+ if cErr == nil && portalUserCustomer != nil {
+ cloudWorkspaceOwnerEmailAddress = portalUserCustomer.Email
+ }
+ }
+
+ for _, user := range users {
+ if err = c.App.Srv().EmailService.SendIPFiltersChangedEmail(user.Email, initiatingUser[0], *c.App.Config().ServiceSettings.SiteURL, *c.App.Config().CloudSettings.CWSURL, user.Locale, cloudWorkspaceOwnerEmailAddress == user.Email); err != nil {
+ mlog.Error("Error while sending IP filters changed email", mlog.Err(err))
+ }
+ }
+ })
+
+ if err := json.NewEncoder(w).Encode(updatedAllowedRanges); err != nil {
+ c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func myIP(c *Context, w http.ResponseWriter, r *http.Request) {
+ _, ok := ensureIPFilteringInterface(c, "myIP")
+
+ if !ok {
+ return
+ }
+
+ response := &model.GetIPAddressResponse{
+ IP: c.AppContext.IPAddress(),
+ }
+
+ json, err := json.Marshal(response)
+ if err != nil {
+ c.Err = model.NewAppError("myIP", "api.context.ip_filtering.get_my_ip.failed", nil, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Write(json)
+}
diff --git a/server/channels/api4/ip_filtering_test.go b/server/channels/api4/ip_filtering_test.go
new file mode 100644
index 0000000000..cedf79cb67
--- /dev/null
+++ b/server/channels/api4/ip_filtering_test.go
@@ -0,0 +1,310 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+package api4
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/mattermost/mattermost/server/public/model"
+ "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
+ "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_getIPFilters(t *testing.T) {
+ lic := &model.License{
+ Features: &model.Features{
+ CustomPermissionsSchemes: model.NewBool(false),
+ Cloud: model.NewBool(true),
+ },
+ Customer: &model.Customer{
+ Name: "TestName",
+ Email: "test@example.com",
+ },
+ SkuName: "SKU NAME",
+ SkuShortName: model.LicenseShortSkuEnterprise,
+ StartsAt: model.GetMillis() - 1000,
+ ExpiresAt: model.GetMillis() + 100000,
+ }
+
+ t.Run("No license returns 501", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+
+ th.App.Srv().RemoveLicense()
+
+ th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
+
+ ipFilters, r, err := th.Client.GetIPFilters(context.Background())
+ require.Error(t, err)
+ require.Nil(t, ipFilters)
+ require.Equal(t, 501, r.StatusCode)
+ })
+
+ t.Run("No feature flag returns 501", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "false")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+
+ th.App.Srv().SetLicense(lic)
+
+ th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
+
+ ipFilters, r, err := th.Client.GetIPFilters(context.Background())
+ require.Error(t, err)
+ require.Nil(t, ipFilters)
+ require.Equal(t, 501, r.StatusCode)
+ })
+
+ t.Run("Feature flag and license but no permission", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+
+ th.App.Srv().SetLicense(lic)
+
+ th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
+
+ ipFilters, r, err := th.Client.GetIPFilters(context.Background())
+ require.Error(t, err)
+ require.Nil(t, ipFilters)
+ require.Equal(t, 403, r.StatusCode)
+ })
+
+ t.Run("Feature flag and license and permission", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFiltering.Mock.On("GetIPFilters").Return(&model.AllowedIPRanges{
+ model.AllowedIPRange{
+ CIDRBlock: "127.0.0.1/32",
+ Description: "test",
+ },
+ }, nil)
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+
+ th.App.Srv().SetLicense(lic)
+
+ th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
+
+ ipFilters, r, err := th.Client.GetIPFilters(context.Background())
+ require.NoError(t, err)
+ require.NotNil(t, ipFilters)
+ require.Equal(t, 200, r.StatusCode)
+ })
+}
+
+func Test_applyIPFilters(t *testing.T) {
+ allowedRanges := &model.AllowedIPRanges{
+ model.AllowedIPRange{
+ CIDRBlock: "127.0.0.1/32",
+ Description: "test",
+ },
+ }
+
+ lic := &model.License{
+ Features: &model.Features{
+ CustomPermissionsSchemes: model.NewBool(false),
+ Cloud: model.NewBool(true),
+ },
+ Customer: &model.Customer{
+ Name: "TestName",
+ Email: "test@example.com",
+ },
+ SkuName: "SKU NAME",
+ SkuShortName: model.LicenseShortSkuEnterprise,
+ StartsAt: model.GetMillis() - 1000,
+ ExpiresAt: model.GetMillis() + 100000,
+ }
+ // Initialize the allowedRanges variable
+ t.Run("No license returns 501", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+
+ th.App.Srv().RemoveLicense()
+
+ th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
+
+ ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges)
+ require.Error(t, err)
+ require.Nil(t, ipFilters)
+ require.Equal(t, 501, r.StatusCode)
+ })
+
+ t.Run("License but no feature flag returns 501", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "false")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+ th.App.Srv().SetLicense(lic)
+
+ th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
+
+ ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges)
+ require.Error(t, err)
+ require.Nil(t, ipFilters)
+ require.Equal(t, 501, r.StatusCode)
+ })
+
+ t.Run("feature flag and license but no permission", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+ th.App.Srv().SetLicense(lic)
+
+ ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges)
+ require.Error(t, err)
+ require.Nil(t, ipFilters)
+ require.Equal(t, 403, r.StatusCode)
+ })
+
+ t.Run("Feature flag and license and permission", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFiltering.Mock.On("ApplyIPFilters", mock.Anything).Return(&model.AllowedIPRanges{
+ model.AllowedIPRange{
+ CIDRBlock: "127.0.0.1/32",
+ Description: "test",
+ },
+ }, nil)
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+
+ th.App.Srv().SetLicense(lic)
+
+ th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
+
+ ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges)
+ require.NoError(t, err)
+ require.NotNil(t, ipFilters)
+ require.Equal(t, 200, r.StatusCode)
+ })
+}
+
+func Test_getMyIP(t *testing.T) {
+ lic := &model.License{
+ Features: &model.Features{
+ CustomPermissionsSchemes: model.NewBool(false),
+ Cloud: model.NewBool(true),
+ },
+ Customer: &model.Customer{
+ Name: "TestName",
+ Email: "test@example.com",
+ },
+ SkuName: "SKU NAME",
+ SkuShortName: model.LicenseShortSkuEnterprise,
+ StartsAt: model.GetMillis() - 1000,
+ ExpiresAt: model.GetMillis() + 100000,
+ }
+ t.Run("No license returns 501", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+
+ th.App.Srv().RemoveLicense()
+
+ th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
+
+ myIP, r, err := th.Client.GetMyIP(context.Background())
+ require.Error(t, err)
+ require.Nil(t, myIP)
+ require.Equal(t, 501, r.StatusCode)
+ })
+
+ t.Run("Licensed, but no feature flag returns 501", func(t *testing.T) {
+ os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "false")
+ defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING")
+ th := Setup(t).InitBasic()
+ defer th.TearDown()
+
+ th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
+
+ ipFiltering := &mocks.IPFilteringInterface{}
+ ipFilteringImpl := th.App.Srv().IPFiltering
+ defer func() {
+ th.App.Srv().IPFiltering = ipFilteringImpl
+ }()
+ th.App.Srv().IPFiltering = ipFiltering
+ th.App.Srv().SetLicense(lic)
+
+ myIP, r, err := th.Client.GetMyIP(context.Background())
+ require.Error(t, err)
+ require.Nil(t, myIP)
+ require.Equal(t, 501, r.StatusCode)
+ })
+}
diff --git a/server/channels/app/app.go b/server/channels/app/app.go
index aacc93bf35..e0b9b9bf2f 100644
--- a/server/channels/app/app.go
+++ b/server/channels/app/app.go
@@ -103,6 +103,11 @@ func (a *App) Saml() einterfaces.SamlInterface {
func (a *App) Cloud() einterfaces.CloudInterface {
return a.ch.srv.Cloud
}
+
+func (a *App) IPFiltering() einterfaces.IPFilteringInterface {
+ return a.ch.srv.IPFiltering
+}
+
func (a *App) HTTPService() httpservice.HTTPService {
return a.ch.srv.httpService
}
diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go
index 25efd650db..178635ae3a 100644
--- a/server/channels/app/app_iface.go
+++ b/server/channels/app/app_iface.go
@@ -868,6 +868,7 @@ type AppIface interface {
HasPermissionToTeam(c request.CTX, askingUserId string, teamID string, permission *model.Permission) bool
HasPermissionToUser(askingUserId string, userID string) bool
HasSharedChannel(channelID string) (bool, error)
+ IPFiltering() einterfaces.IPFilteringInterface
ImageProxy() *imageproxy.ImageProxy
ImageProxyAdder() func(string) string
ImageProxyRemover() (f func(string) string)
diff --git a/server/channels/app/email/email.go b/server/channels/app/email/email.go
index 21996d66b9..3ef23f74ab 100644
--- a/server/channels/app/email/email.go
+++ b/server/channels/app/email/email.go
@@ -1275,3 +1275,40 @@ func (es *Service) SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale
return nil
}
+
+func (es *Service) SendIPFiltersChangedEmail(email string, initiatingUser *model.User, siteURL, portalURL, locale string, isWorkspaceOwner bool) error {
+ T := i18n.GetUserTranslations(locale)
+
+ subject := T("api.templates.ip_filters_changed.subject")
+
+ data := es.NewEmailTemplateData(locale)
+ data.Props["SiteURL"] = siteURL
+ data.Props["Title"] = T("api.templates.ip_filters_changed.title")
+ data.Props["SubTitle"] = T("api.templates.ip_filters_changed.subTitle", map[string]any{"InitiatingUsername": initiatingUser.Username, "SiteURL": siteURL})
+ data.Props["ButtonURL"] = siteURL + "/admin_console/site_config/ip_filtering"
+ data.Props["Button"] = T("api.templates.ip_filters_changed.button")
+ data.Props["TroubleAccessingTitle"] = T("api.templates.ip_filters_changed_footer.title")
+ data.Props["SendAnEmailTo"] = T("api.templates.ip_filters_changed_footer.send_an_email_to", map[string]any{"InitiatingUserEmail": initiatingUser.Email})
+ data.Props["PortalURL"] = portalURL
+ // If the email we're sending to was the one who initiated the change, we don't want to show their email address as a mailto
+ if email != initiatingUser.Email {
+ data.Props["ActorEmail"] = initiatingUser.Email
+ }
+
+ if isWorkspaceOwner {
+ data.Props["LogInToCustomerPortal"] = T("api.templates.ip_filters_changed_footer.log_in_to_customer_portal")
+ }
+ data.Props["ContactSupport"] = T("api.templates.ip_filters_changed_footer.contact_support")
+ data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail
+
+ body, err := es.templatesContainer.RenderToString("ip_filters_changed", data)
+ if err != nil {
+ return err
+ }
+
+ if err := es.sendMail(email, subject, body, "PasswordResetEmail"); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/server/channels/app/email/mocks/ServiceInterface.go b/server/channels/app/email/mocks/ServiceInterface.go
index 588c3c03a1..8597124ea8 100644
--- a/server/channels/app/email/mocks/ServiceInterface.go
+++ b/server/channels/app/email/mocks/ServiceInterface.go
@@ -310,6 +310,20 @@ func (_m *ServiceInterface) SendGuestInviteEmails(team *model.Team, channels []*
return r0
}
+// SendIPFiltersChangedEmail provides a mock function with given fields: _a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner
+func (_m *ServiceInterface) SendIPFiltersChangedEmail(_a0 string, userWhoChangedFilter *model.User, siteURL string, portalURL string, locale string, isWorkspaceOwner bool) error {
+ ret := _m.Called(_a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string, *model.User, string, string, string, bool) error); ok {
+ r0 = rf(_a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
// SendInviteEmails provides a mock function with given fields: team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin
func (_m *ServiceInterface) SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) error {
ret := _m.Called(team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
diff --git a/server/channels/app/email/service.go b/server/channels/app/email/service.go
index 14d87a7616..af57bc0e7c 100644
--- a/server/channels/app/email/service.go
+++ b/server/channels/app/email/service.go
@@ -163,6 +163,7 @@ type ServiceInterface interface {
InitEmailBatching()
SendChangeUsernameEmail(newUsername, email, locale, siteURL string) error
CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error)
+ SendIPFiltersChangedEmail(email string, userWhoChangedFilter *model.User, siteURL, portalURL, locale string, isWorkspaceOwner bool) error
Stop()
}
diff --git a/server/channels/app/enterprise.go b/server/channels/app/enterprise.go
index 4638932bfb..ae3cb01e95 100644
--- a/server/channels/app/enterprise.go
+++ b/server/channels/app/enterprise.go
@@ -92,6 +92,12 @@ func RegisterNotificationInterface(f func(*App) einterfaces.NotificationInterfac
notificationInterface = f
}
+var ipFilteringInterface func(*App) einterfaces.IPFilteringInterface
+
+func RegisterIPFilteringInterface(f func(*App) einterfaces.IPFilteringInterface) {
+ ipFilteringInterface = f
+}
+
func (s *Server) initEnterprise() {
if cloudInterface != nil {
s.Cloud = cloudInterface(s)
diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go
index 7b13ee9216..15bd6138d6 100644
--- a/server/channels/app/opentracing/opentracing_layer.go
+++ b/server/channels/app/opentracing/opentracing_layer.go
@@ -11560,6 +11560,23 @@ func (a *OpenTracingAppLayer) HubUnregister(webConn *platform.WebConn) {
a.app.HubUnregister(webConn)
}
+func (a *OpenTracingAppLayer) IPFiltering() einterfaces.IPFilteringInterface {
+ origCtx := a.ctx
+ span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IPFiltering")
+
+ a.ctx = newCtx
+ a.app.Srv().Store().SetContext(newCtx)
+ defer func() {
+ a.app.Srv().Store().SetContext(origCtx)
+ a.ctx = origCtx
+ }()
+
+ defer span.Finish()
+ resultVar0 := a.app.IPFiltering()
+
+ return resultVar0
+}
+
func (a *OpenTracingAppLayer) ImageProxyAdder() func(string) string {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ImageProxyAdder")
diff --git a/server/channels/app/permissions_migrations.go b/server/channels/app/permissions_migrations.go
index 57e13e9217..fd8052213b 100644
--- a/server/channels/app/permissions_migrations.go
+++ b/server/channels/app/permissions_migrations.go
@@ -1117,6 +1117,30 @@ func (a *App) getAddChannelReadContentPermissions() (permissionsMap, error) {
return t, nil
}
+func (a *App) getAddIPFilterPermissionsMigration() (permissionsMap, error) {
+ t := []permissionTransformation{}
+
+ ipFilterPermissionsRead := []string{
+ model.PermissionSysconsoleReadIPFilters.Id,
+ }
+
+ ipFilterPermissionsWrite := []string{
+ model.PermissionSysconsoleWriteIPFilters.Id,
+ }
+
+ t = append(t, permissionTransformation{
+ On: permissionOr(isExactRole(model.SystemAdminRoleId)),
+ Add: ipFilterPermissionsRead,
+ })
+
+ t = append(t, permissionTransformation{
+ On: permissionOr(isExactRole(model.SystemAdminRoleId)),
+ Add: ipFilterPermissionsWrite,
+ })
+
+ return t, nil
+}
+
// DoPermissionsMigrations execute all the permissions migrations need by the current version.
func (a *App) DoPermissionsMigrations() error {
return a.Srv().doPermissionsMigrations()
@@ -1161,6 +1185,7 @@ func (s *Server) doPermissionsMigrations() error {
{Key: model.MigrationKeyAddProductsBoardsPermissions, Migration: a.getProductsBoardsPermissions},
{Key: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Migration: a.getAddCustomUserGroupsPermissionRestore},
{Key: model.MigrationKeyAddReadChannelContentPermissions, Migration: a.getAddChannelReadContentPermissions},
+ {Key: model.MigrationKeyAddIPFilteringPermissions, Migration: a.getAddIPFilterPermissionsMigration},
}
roles, err := s.Store().Role().GetAll()
diff --git a/server/channels/app/server.go b/server/channels/app/server.go
index a3b5c4b7ac..7a834f2b80 100644
--- a/server/channels/app/server.go
+++ b/server/channels/app/server.go
@@ -141,7 +141,8 @@ type Server struct {
// startSearchEngine bool
skipPostInit bool
- Cloud einterfaces.CloudInterface
+ Cloud einterfaces.CloudInterface
+ IPFiltering einterfaces.IPFilteringInterface
tracer *tracing.Tracer
@@ -396,6 +397,10 @@ func NewServer(options ...Option) (*Server, error) {
s.initJobs()
+ if ipFilteringInterface != nil {
+ s.IPFiltering = ipFilteringInterface(app)
+ }
+
s.clusterLeaderListenerId = s.AddClusterLeaderChangedListener(func() {
mlog.Info("Cluster leader changed. Determining if job schedulers should be running:", mlog.Bool("isLeader", s.IsLeader()))
if s.Jobs != nil {
diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go
index f6a28a6490..deea701b16 100644
--- a/server/channels/testlib/store.go
+++ b/server/channels/testlib/store.go
@@ -72,6 +72,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
systemStore.On("GetByName", model.MigrationKeyAddCustomUserGroupsPermissionRestore).Return(&model.System{Name: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddReadChannelContentPermissions).Return(&model.System{Name: model.MigrationKeyAddReadChannelContentPermissions, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyDeleteEmptyDrafts).Return(&model.System{Name: model.MigrationKeyDeleteEmptyDrafts, Value: "true"}, nil)
+ systemStore.On("GetByName", model.MigrationKeyAddIPFilteringPermissions).Return(&model.System{Name: model.MigrationKeyAddIPFilteringPermissions, Value: "true"}, nil)
systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil)
systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil)
systemStore.On("GetByName", "elasticsearch_fix_channel_index_migration").Return(&model.System{Name: "elasticsearch_fix_channel_index_migration", Value: "true"}, nil)
diff --git a/server/einterfaces/cloud.go b/server/einterfaces/cloud.go
index 9b56dc413e..c447a611c8 100644
--- a/server/einterfaces/cloud.go
+++ b/server/einterfaces/cloud.go
@@ -53,4 +53,7 @@ type CloudInterface interface {
// Used only for when a customer has telemetry disabled. In this scenario, true up review telemetry will be submitted via CWS.
SubmitTrueUpReview(userID string, trueUpReviewProfile map[string]any) error
+
+ ApplyIPFilters(userID string, ranges *model.AllowedIPRanges) (*model.AllowedIPRanges, error)
+ GetIPFilters(userID string) (*model.AllowedIPRanges, error)
}
diff --git a/server/einterfaces/ip_filtering.go b/server/einterfaces/ip_filtering.go
new file mode 100644
index 0000000000..4bdf0333f6
--- /dev/null
+++ b/server/einterfaces/ip_filtering.go
@@ -0,0 +1,11 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+package einterfaces
+
+import "github.com/mattermost/mattermost/server/public/model"
+
+type IPFilteringInterface interface {
+ ApplyIPFilters(allowedIPRanges *model.AllowedIPRanges) (*model.AllowedIPRanges, error)
+ GetIPFilters() (*model.AllowedIPRanges, error)
+}
diff --git a/server/einterfaces/mocks/CloudInterface.go b/server/einterfaces/mocks/CloudInterface.go
index 5fb6f19646..86d7b2708e 100644
--- a/server/einterfaces/mocks/CloudInterface.go
+++ b/server/einterfaces/mocks/CloudInterface.go
@@ -15,6 +15,32 @@ type CloudInterface struct {
mock.Mock
}
+// ApplyIPFilters provides a mock function with given fields: userID, ranges
+func (_m *CloudInterface) ApplyIPFilters(userID string, ranges *model.AllowedIPRanges) (*model.AllowedIPRanges, error) {
+ ret := _m.Called(userID, ranges)
+
+ var r0 *model.AllowedIPRanges
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string, *model.AllowedIPRanges) (*model.AllowedIPRanges, error)); ok {
+ return rf(userID, ranges)
+ }
+ if rf, ok := ret.Get(0).(func(string, *model.AllowedIPRanges) *model.AllowedIPRanges); ok {
+ r0 = rf(userID, ranges)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.AllowedIPRanges)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(string, *model.AllowedIPRanges) error); ok {
+ r1 = rf(userID, ranges)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
// BootstrapSelfHostedSignup provides a mock function with given fields: req
func (_m *CloudInterface) BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) {
ret := _m.Called(req)
@@ -343,6 +369,32 @@ func (_m *CloudInterface) GetCloudProducts(userID string, includeLegacyProducts
return r0, r1
}
+// GetIPFilters provides a mock function with given fields: userID
+func (_m *CloudInterface) GetIPFilters(userID string) (*model.AllowedIPRanges, error) {
+ ret := _m.Called(userID)
+
+ var r0 *model.AllowedIPRanges
+ var r1 error
+ if rf, ok := ret.Get(0).(func(string) (*model.AllowedIPRanges, error)); ok {
+ return rf(userID)
+ }
+ if rf, ok := ret.Get(0).(func(string) *model.AllowedIPRanges); ok {
+ r0 = rf(userID)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.AllowedIPRanges)
+ }
+ }
+
+ 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/einterfaces/mocks/IPFilteringInterface.go b/server/einterfaces/mocks/IPFilteringInterface.go
new file mode 100644
index 0000000000..196fe49406
--- /dev/null
+++ b/server/einterfaces/mocks/IPFilteringInterface.go
@@ -0,0 +1,82 @@
+// Code generated by mockery v2.23.2. DO NOT EDIT.
+
+// Regenerate this file using `make einterfaces-mocks`.
+
+package mocks
+
+import (
+ model "github.com/mattermost/mattermost/server/public/model"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// IPFilteringInterface is an autogenerated mock type for the IPFilteringInterface type
+type IPFilteringInterface struct {
+ mock.Mock
+}
+
+// ApplyIPFilters provides a mock function with given fields: allowedIPRanges
+func (_m *IPFilteringInterface) ApplyIPFilters(allowedIPRanges *model.AllowedIPRanges) (*model.AllowedIPRanges, error) {
+ ret := _m.Called(allowedIPRanges)
+
+ var r0 *model.AllowedIPRanges
+ var r1 error
+ if rf, ok := ret.Get(0).(func(*model.AllowedIPRanges) (*model.AllowedIPRanges, error)); ok {
+ return rf(allowedIPRanges)
+ }
+ if rf, ok := ret.Get(0).(func(*model.AllowedIPRanges) *model.AllowedIPRanges); ok {
+ r0 = rf(allowedIPRanges)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.AllowedIPRanges)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(*model.AllowedIPRanges) error); ok {
+ r1 = rf(allowedIPRanges)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// GetIPFilters provides a mock function with given fields:
+func (_m *IPFilteringInterface) GetIPFilters() (*model.AllowedIPRanges, error) {
+ ret := _m.Called()
+
+ var r0 *model.AllowedIPRanges
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (*model.AllowedIPRanges, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() *model.AllowedIPRanges); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*model.AllowedIPRanges)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+type mockConstructorTestingTNewIPFilteringInterface interface {
+ mock.TestingT
+ Cleanup(func())
+}
+
+// NewIPFilteringInterface creates a new instance of IPFilteringInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+func NewIPFilteringInterface(t mockConstructorTestingTNewIPFilteringInterface) *IPFilteringInterface {
+ mock := &IPFilteringInterface{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/server/i18n/en.json b/server/i18n/en.json
index 8000bd55f0..19c0282d8c 100644
--- a/server/i18n/en.json
+++ b/server/i18n/en.json
@@ -1625,6 +1625,22 @@
"id": "api.context.invitation_expired.error",
"translation": "Invitation is expired."
},
+ {
+ "id": "api.context.ip_filtering.apply_ip_filters.app_error",
+ "translation": "An error has occurred while applying IP Filters"
+ },
+ {
+ "id": "api.context.ip_filtering.get_ip_filters.app_error",
+ "translation": "An error has occurred while fetching IP Filters"
+ },
+ {
+ "id": "api.context.ip_filtering.get_my_ip.failed",
+ "translation": "An error has occurred while fetching the client's IP address"
+ },
+ {
+ "id": "api.context.ip_filtering.not_available.app_error",
+ "translation": "IP Filtering is not available on this server"
+ },
{
"id": "api.context.json_encoding.app_error",
"translation": "Error encoding JSON."
@@ -3706,6 +3722,38 @@
"id": "api.templates.invite_team_and_channels_subject",
"translation": "[{{ .SiteName }}] {{ .SenderName }} invited you to join {{ .ChannelsLen }} channels on the {{ .TeamDisplayName }} Team"
},
+ {
+ "id": "api.templates.ip_filters_changed.button",
+ "translation": "Review changes"
+ },
+ {
+ "id": "api.templates.ip_filters_changed.subTitle",
+ "translation": "@{{ .InitiatingUsername }} changed the IP filtering settings for your workspace at the URL: {{ .SiteURL }}"
+ },
+ {
+ "id": "api.templates.ip_filters_changed.subject",
+ "translation": "Changes to Your Workspace's IP Filters"
+ },
+ {
+ "id": "api.templates.ip_filters_changed.title",
+ "translation": "IP filtering changes for your workspace"
+ },
+ {
+ "id": "api.templates.ip_filters_changed_footer.contact_support",
+ "translation": "Contact support"
+ },
+ {
+ "id": "api.templates.ip_filters_changed_footer.log_in_to_customer_portal",
+ "translation": "Log in to the customer portal to reset IP filtering"
+ },
+ {
+ "id": "api.templates.ip_filters_changed_footer.send_an_email_to",
+ "translation": "Send an email to {{ .InitiatingUserEmail }}"
+ },
+ {
+ "id": "api.templates.ip_filters_changed_footer.title",
+ "translation": "Having trouble accessing your workspace?"
+ },
{
"id": "api.templates.license_up_for_renewal_contact_sales",
"translation": "Contact sales"
diff --git a/server/public/model/client4.go b/server/public/model/client4.go
index 53306f4aef..ce06429f7f 100644
--- a/server/public/model/client4.go
+++ b/server/public/model/client4.go
@@ -555,6 +555,10 @@ func (c *Client4) sharedChannelsRoute() string {
return "/sharedchannels"
}
+func (c *Client4) ipFiltersRoute() string {
+ return "/ip_filtering"
+}
+
func (c *Client4) permissionsRoute() string {
return "/permissions"
}
@@ -8028,6 +8032,52 @@ func (c *Client4) GetProductLimits(ctx context.Context) (*ProductLimits, *Respon
return productLimits, BuildResponse(r), nil
}
+func (c *Client4) GetIPFilters(ctx context.Context) (*AllowedIPRanges, *Response, error) {
+ r, err := c.DoAPIGet(ctx, c.ipFiltersRoute(), "")
+ if err != nil {
+ return nil, BuildResponse(r), err
+ }
+
+ defer closeBody(r)
+
+ var allowedIPRanges *AllowedIPRanges
+ json.NewDecoder(r.Body).Decode(&allowedIPRanges)
+ return allowedIPRanges, BuildResponse(r), nil
+}
+
+func (c *Client4) ApplyIPFilters(ctx context.Context, allowedRanges *AllowedIPRanges) (*AllowedIPRanges, *Response, error) {
+ payload, err := json.Marshal(allowedRanges)
+ if err != nil {
+ return nil, nil, NewAppError("ApplyIPFilters", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
+ }
+
+ r, err := c.DoAPIPostBytes(ctx, c.ipFiltersRoute(), payload)
+ if err != nil {
+ return nil, BuildResponse(r), err
+ }
+
+ defer closeBody(r)
+
+ var allowedIPRanges *AllowedIPRanges
+ json.NewDecoder(r.Body).Decode(&allowedIPRanges)
+
+ return allowedIPRanges, BuildResponse(r), nil
+}
+
+func (c *Client4) GetMyIP(ctx context.Context) (*GetIPAddressResponse, *Response, error) {
+ r, err := c.DoAPIGet(ctx, c.ipFiltersRoute()+"/my_ip", "")
+ if err != nil {
+ return nil, BuildResponse(r), err
+ }
+
+ defer closeBody(r)
+
+ var response *GetIPAddressResponse
+ json.NewDecoder(r.Body).Decode(&response)
+
+ return response, BuildResponse(r), nil
+}
+
func (c *Client4) CreateCustomerPayment(ctx context.Context) (*StripeSetupIntent, *Response, error) {
r, err := c.DoAPIPost(ctx, c.cloudRoute()+"/payment", "")
if err != nil {
diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go
index 04f4ba9b74..a34f07d851 100644
--- a/server/public/model/feature_flags.go
+++ b/server/public/model/feature_flags.go
@@ -44,7 +44,8 @@ type FeatureFlags struct {
StreamlinedMarketplace bool
- ConsumePostHook bool
+ CloudIPFiltering bool
+ ConsumePostHook bool
}
func (f *FeatureFlags) SetDefaults() {
@@ -60,6 +61,7 @@ func (f *FeatureFlags) SetDefaults() {
f.CloudReverseTrial = false
f.EnableExportDirectDownload = false
f.StreamlinedMarketplace = true
+ f.CloudIPFiltering = false
f.ConsumePostHook = false
}
diff --git a/server/public/model/ip_filtering.go b/server/public/model/ip_filtering.go
new file mode 100644
index 0000000000..75dda05697
--- /dev/null
+++ b/server/public/model/ip_filtering.go
@@ -0,0 +1,20 @@
+package model
+
+type AllowedIPRanges []AllowedIPRange
+
+type AllowedIPRange struct {
+ CIDRBlock string `json:"cidr_block"`
+ Description string `json:"description"`
+ Enabled bool `json:"enabled"`
+ OwnerID string `json:"owner_id"`
+}
+
+func (air *AllowedIPRanges) Auditable() map[string]interface{} {
+ return map[string]interface{}{
+ "AllowedIPRanges": air,
+ }
+}
+
+type GetIPAddressResponse struct {
+ IP string `json:"ip"`
+}
diff --git a/server/public/model/migration.go b/server/public/model/migration.go
index faa33a0670..12692e6f83 100644
--- a/server/public/model/migration.go
+++ b/server/public/model/migration.go
@@ -44,4 +44,5 @@ const (
MigrationKeyElasticsearchFixChannelIndex = "elasticsearch_fix_channel_index_migration"
MigrationKeyS3Path = "s3_path_migration"
MigrationKeyDeleteEmptyDrafts = "delete_empty_drafts_migration"
+ MigrationKeyAddIPFilteringPermissions = "add_ip_filtering_permissions"
)
diff --git a/server/public/model/permission.go b/server/public/model/permission.go
index 88c35798fb..db68995d64 100644
--- a/server/public/model/permission.go
+++ b/server/public/model/permission.go
@@ -267,6 +267,9 @@ var PermissionSysconsoleWriteSitePublicLinks *Permission
var PermissionSysconsoleReadSiteNotices *Permission
var PermissionSysconsoleWriteSiteNotices *Permission
+var PermissionSysconsoleReadIPFilters *Permission
+var PermissionSysconsoleWriteIPFilters *Permission
+
var PermissionSysconsoleReadAuthentication *Permission
var PermissionSysconsoleWriteAuthentication *Permission
@@ -1646,6 +1649,20 @@ func initializePermissions() {
PermissionScopeSystem,
}
+ PermissionSysconsoleReadIPFilters = &Permission{
+ "sysconsole_read_site_ip_filters",
+ "",
+ "",
+ PermissionScopeSystem,
+ }
+
+ PermissionSysconsoleWriteIPFilters = &Permission{
+ "sysconsole_write_site_ip_filters",
+ "",
+ "",
+ PermissionScopeSystem,
+ }
+
// Deprecated
PermissionSysconsoleReadAuthentication = &Permission{
"sysconsole_read_authentication",
@@ -2160,6 +2177,7 @@ func initializePermissions() {
PermissionSysconsoleReadExperimentalFeatureFlags,
PermissionSysconsoleReadExperimentalBleve,
PermissionSysconsoleReadProductsBoards,
+ PermissionSysconsoleReadIPFilters,
}
SysconsoleWritePermissions = []*Permission{
@@ -2218,6 +2236,7 @@ func initializePermissions() {
PermissionSysconsoleWriteExperimentalFeatureFlags,
PermissionSysconsoleWriteExperimentalBleve,
PermissionSysconsoleWriteProductsBoards,
+ PermissionSysconsoleWriteIPFilters,
}
SystemScopedPermissionsMinusSysconsole := []*Permission{
diff --git a/server/templates/cloud_14_day_arrears.html b/server/templates/cloud_14_day_arrears.html
index d5115d151e..ebd1e3dcc1 100644
--- a/server/templates/cloud_14_day_arrears.html
+++ b/server/templates/cloud_14_day_arrears.html
@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/cloud_30_day_arrears.html b/server/templates/cloud_30_day_arrears.html
index 40cc01ef49..47ef5b5c84 100644
--- a/server/templates/cloud_30_day_arrears.html
+++ b/server/templates/cloud_30_day_arrears.html
@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/cloud_45_day_arrears.html b/server/templates/cloud_45_day_arrears.html
index 58d7859d95..bf4d45e85e 100644
--- a/server/templates/cloud_45_day_arrears.html
+++ b/server/templates/cloud_45_day_arrears.html
@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/cloud_7_day_arrears.html b/server/templates/cloud_7_day_arrears.html
index 712b29fa37..70230a6157 100644
--- a/server/templates/cloud_7_day_arrears.html
+++ b/server/templates/cloud_7_day_arrears.html
@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/cloud_90_day_arrears.html b/server/templates/cloud_90_day_arrears.html
index 5bdeacbab7..2a5ac6913f 100644
--- a/server/templates/cloud_90_day_arrears.html
+++ b/server/templates/cloud_90_day_arrears.html
@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/cloud_upgrade_confirmation.html b/server/templates/cloud_upgrade_confirmation.html
index 27f4fca15c..f676b61c46 100644
--- a/server/templates/cloud_upgrade_confirmation.html
+++ b/server/templates/cloud_upgrade_confirmation.html
@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/invite_body.html b/server/templates/invite_body.html
index 8a3b232b65..dc6fe22076 100644
--- a/server/templates/invite_body.html
+++ b/server/templates/invite_body.html
@@ -306,6 +306,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/ip_filters_changed.html b/server/templates/ip_filters_changed.html
new file mode 100644
index 0000000000..af27433b86
--- /dev/null
+++ b/server/templates/ip_filters_changed.html
@@ -0,0 +1,580 @@
+{{define "ip_filters_changed"}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ {{.Props.Title}}
+ |
+
+
+ |
+ {{.Props.SubTitle}}
+ |
+
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{if .Props.ActorEmail}}
+
+ |
+
+ |
+
+
+ |
+
+
+
+ |
+
+ {{end}}{{ if .Props.LogInToCustomerPortal}}
+
+ |
+
+ |
+
+
+ |
+
+
+
+ |
+
+ {{end}}
+
+ |
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/server/templates/ip_filters_changed.mjml b/server/templates/ip_filters_changed.mjml
new file mode 100644
index 0000000000..e392a59e8e
--- /dev/null
+++ b/server/templates/ip_filters_changed.mjml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{.Props.TroubleAccessingTitle}}
+
+ {{if .Props.ActorEmail}}
+
+ {{.Props.SendAnEmailTo}}
+
+
+ {{end}}
+ {{ if .Props.LogInToCustomerPortal}}
+
+ {{.Props.LogInToCustomerPortal}}
+
+
+ {{end}}
+
+ {{.Props.ContactSupport}}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server/templates/license_up_for_renewal.html b/server/templates/license_up_for_renewal.html
index d05cc8d8be..d9daa4cce4 100644
--- a/server/templates/license_up_for_renewal.html
+++ b/server/templates/license_up_for_renewal.html
@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/messages_notification.html b/server/templates/messages_notification.html
index ba2aaee252..ba94db3b3e 100644
--- a/server/templates/messages_notification.html
+++ b/server/templates/messages_notification.html
@@ -306,6 +306,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/partials/style.css b/server/templates/partials/style.css
index 3c72f05ea0..8759cddb23 100644
--- a/server/templates/partials/style.css
+++ b/server/templates/partials/style.css
@@ -193,6 +193,10 @@
padding: 0px 0px 0px 12px !important;
}
+.divider {
+ opacity: 12%;
+}
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/payment_failed_body.html b/server/templates/payment_failed_body.html
index 81060f9e37..97cdea917a 100644
--- a/server/templates/payment_failed_body.html
+++ b/server/templates/payment_failed_body.html
@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/reset_body.html b/server/templates/reset_body.html
index ab2b11be32..3ead187da7 100644
--- a/server/templates/reset_body.html
+++ b/server/templates/reset_body.html
@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/verify_body.html b/server/templates/verify_body.html
index a94df5f287..aeeb616400 100644
--- a/server/templates/verify_body.html
+++ b/server/templates/verify_body.html
@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/server/templates/welcome_body.html b/server/templates/welcome_body.html
index 5e85097a31..fc8732dbc3 100644
--- a/server/templates/welcome_body.html
+++ b/server/templates/welcome_body.html
@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
+ .divider {
+ opacity: 12%;
+ }
+
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
diff --git a/webapp/channels/jest.config.js b/webapp/channels/jest.config.js
index 9f07905e88..c7972e9f3d 100644
--- a/webapp/channels/jest.config.js
+++ b/webapp/channels/jest.config.js
@@ -38,7 +38,7 @@ const config = {
['jest-junit', {outputDirectory: 'build', outputName: 'test-results.xml'}],
],
transformIgnorePatterns: [
- 'node_modules/(?!react-native|react-router|p-queue|p-timeout|@mattermost/compass-components|@mattermost/compass-icons)',
+ 'node_modules/(?!react-native|react-router|p-queue|p-timeout|@mattermost/compass-components|@mattermost/compass-icons|cidr-regex|ip-regex)',
],
setupFiles: ['jest-canvas-mock'],
setupFilesAfterEnv: ['/src/tests/setup_jest.ts'],
diff --git a/webapp/channels/package.json b/webapp/channels/package.json
index dbba461960..9b412f53c9 100644
--- a/webapp/channels/package.json
+++ b/webapp/channels/package.json
@@ -45,6 +45,7 @@
"hoist-non-react-statics": "3.3.2",
"html-to-react": "1.6.0",
"inobounce": "0.2.1",
+ "ipaddr.js": "2.1.0",
"katex": "0.16.3",
"key-mirror": "1.0.1",
"localforage": "1.10.0",
diff --git a/webapp/channels/src/actions/admin_actions.jsx b/webapp/channels/src/actions/admin_actions.jsx
index 366f8cbe5e..a3bfecd744 100644
--- a/webapp/channels/src/actions/admin_actions.jsx
+++ b/webapp/channels/src/actions/admin_actions.jsx
@@ -91,6 +91,33 @@ export async function samlCertificateStatus(success, error) {
}
}
+export async function getIPFilters(success, error) {
+ const {data, error: err} = await AdminActions.getIPFilters()(dispatch, getState);
+ if (data && success) {
+ success(data);
+ } else if (err && error) {
+ error(err);
+ }
+}
+
+export async function getCurrentIP(success, error) {
+ const {data, error: err} = await AdminActions.getCurrentIP()(dispatch, getState);
+ if (data && success) {
+ success(data);
+ } else if (err && error) {
+ error(err);
+ }
+}
+
+export async function applyIPFilters(ipList, success, error) {
+ const {data, error: err} = await AdminActions.applyIPFilters(ipList)(dispatch, getState);
+ if (data && success) {
+ success(data);
+ } else if (err && error) {
+ error(err);
+ }
+}
+
export function getOAuthAppInfo(clientId) {
return bindClientFunc({
clientFunc: Client4.getOAuthAppInfo,
diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx
index 24d7489b4f..7aada60a25 100644
--- a/webapp/channels/src/components/admin_console/admin_definition.tsx
+++ b/webapp/channels/src/components/admin_console/admin_definition.tsx
@@ -73,6 +73,7 @@ import {
import FeatureFlags from './feature_flags';
import GroupDetails from './group_settings/group_details';
import GroupSettings from './group_settings/group_settings';
+import IPFiltering from './ip_filtering';
import LicenseSettings from './license_settings';
import MessageExportSettings from './message_export_settings';
import OpenIdConvert from './openid_convert';
@@ -3289,6 +3290,20 @@ const AdminDefinition: AdminDefinitionType = {
],
},
},
+ ip_filtering: {
+ url: 'site_config/ip_filtering',
+ title: t('admin.sidebar.ip_filtering'),
+ title_default: 'IP Filtering',
+ isHidden: it.not(it.all(it.configIsTrue('FeatureFlags', 'CloudIPFiltering'), it.licensedForSku('enterprise'))),
+ isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.IP_FILTERING)),
+ searchableStrings: [
+ 'admin.sidebar.ip_filtering',
+ ],
+ schema: {
+ id: 'IPFiltering',
+ component: IPFiltering,
+ },
+ },
},
},
authentication: {
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.scss b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.scss
new file mode 100644
index 0000000000..c2ebdd633c
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.scss
@@ -0,0 +1,202 @@
+.IPFilteringAddOrEditModal {
+ .modal-dialog {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 600px;
+ border: 1 px solid rgba(var(--center-channel-color-rgb), 0.08);
+ margin: auto;
+ border-radius: 12px;
+ transform: translate(-50%, -50%) !important;
+ }
+
+ .modal-content {
+ border-radius: 12px;
+ }
+
+ .modal-header {
+ .close {
+ &:hover {
+ background-color:
+ rgba(
+ var(--center-channel-color-rgb),
+ 0.08
+ );
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+ }
+
+ &:active {
+ background-color: rgba(var(--button-bg-rgb), 0.08);
+ color: var(--button-bg);
+ }
+
+ top: 26px;
+ right: 26px;
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ color: rgba(var(--center-channel-color-rgb), 0.56) !important;
+ font-family:
+ 'Open Sans',
+ sans-serif;
+ font-size: 32px;
+ font-weight: 400;
+ }
+
+ .title {
+ font-family: Metropolis, sans-serif;
+ font-size: 22px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 28px;
+ text-align: left;
+ }
+
+ display: block;
+ min-height: 48px;
+ padding-top: 26px;
+ padding-right: 32px;
+ padding-bottom: 0;
+ padding-left: 32px;
+ border: 0;
+ background: var(--center-channel-bg) !important;
+ border-radius: 12px;
+ color: var(--center-channel-color);
+ }
+
+ .modal-body {
+ display: flex;
+ overflow: hidden;
+ width: 100%;
+ flex-direction: column;
+ padding-top: 24px;
+ padding-right: 32px;
+ padding-bottom: 26px;
+ padding-left: 32px;
+
+ .current_ip_notice {
+ display: flex;
+ max-width: 536px;
+ height: 52px;
+ border: 1px rgba(87, 158, 255, 0.16) solid;
+ background: rgba(87, 158, 255, 0.08);
+ border-radius: 4px;
+
+ .Content {
+ display: flex;
+ width: 536px;
+ height: 52px;
+ padding: 16px;
+ color: var(--center-channel-color, #3f4350);
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 600;
+ gap: 12px;
+ line-height: 20px;
+
+ span {
+ display: flex;
+ height: 20px;
+ align-items: center;
+ line-height: 20px;
+
+ svg {
+ width: 20px;
+ height: 20px;
+ margin-right: 12px;
+ fill: #5d89ea;
+ }
+ }
+ }
+ }
+
+ .inputs {
+ color: var(--center-channel-color, #3f4350);
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 20px;
+
+ > div {
+ margin-top: 24px;
+
+ .Input_container {
+ margin-top: 8px;
+ }
+
+ input {
+ font-weight: normal;
+ }
+ }
+
+ > p {
+ margin-top: 8px;
+ font-weight: 400;
+ }
+ }
+
+ .buttons {
+ margin-top: 32px;
+ text-align: right;
+
+ .confirm-btn {
+ &:hover,
+ &:active,
+ &:focus,
+ &:active:focus {
+ background:
+ linear-gradient(0deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.16)),
+ var(--button-bg);
+ }
+
+ height: 40px;
+ flex: none;
+ padding: 12px 20px;
+ border: none;
+ margin-left: 8px;
+ background: var(--button-bg);
+ border-radius: 4px;
+ color: var(--sys-button-color);
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 14px;
+ }
+ }
+ }
+
+ .modal-footer {
+ padding: 24px 32px;
+ border-top: none;
+ border-radius: 12px;
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 20px;
+
+ .btn-cancel {
+ padding: 10px 20px;
+ border: none;
+ background: var(--button-bg-8, rgba(28, 88, 217, 0.08));
+ border-radius: 4px;
+ color: var(--button-bg, #1c58d9);
+ }
+
+ .btn-save {
+ &:disabled {
+ background: rgba(63, 67, 80, 0.08);
+ color: rgba(63, 67, 80, 0.32);
+ cursor: not-allowed;
+ }
+
+ padding: 10px 20px;
+ border: none;
+ margin-left: 8px;
+ background: var(--button-bg, #1c58d9);
+ border-radius: 4px;
+ color: var(--button-color, #fff);
+ }
+ }
+}
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.test.tsx
new file mode 100644
index 0000000000..4ee51e6bb6
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.test.tsx
@@ -0,0 +1,144 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fireEvent, waitFor} from '@testing-library/react';
+import React from 'react';
+
+import type {AllowedIPRange} from '@mattermost/types/config';
+
+import {renderWithContext} from 'tests/react_testing_utils';
+
+import IPFilteringAddOrEditModal from './add_edit_ip_filter_modal';
+
+jest.mock('components/external_link', () => {
+ return jest.fn().mockImplementation(({children, ...props}) => {
+ return {children};
+ });
+});
+
+describe('IPFilteringAddOrEditModal', () => {
+ const onExited = jest.fn();
+ const onSave = jest.fn();
+ const existingRange: AllowedIPRange = {
+ cidr_block: '192.168.0.0/16',
+ description: 'Test IP Filter',
+ enabled: true,
+ owner_id: '',
+ };
+ const currentIP = '192.168.0.1';
+
+ const baseProps = {
+ onExited,
+ onSave,
+ existingRange,
+ currentIP,
+ };
+
+ test('renders the modal with the correct title when an existingRange is provided', () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
+
+ expect(getByText('Edit IP Filter')).toBeInTheDocument();
+ });
+
+ test('renders the modal with the correct title when an existingRange is omitted (ie, Add Modal)', () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
+
+ expect(getByText('Add IP Filter')).toBeInTheDocument();
+ });
+
+ test('renders the modal with the correct inputs and values', () => {
+ const {getByLabelText} = renderWithContext(
+ ,
+ );
+
+ expect(getByLabelText('Enter a name for this rule')).toHaveValue('Test IP Filter');
+ expect(getByLabelText('Enter IP Range')).toHaveValue('192.168.0.0/16');
+ });
+
+ test('calls the onSave function with the correct values when the Save button is clicked', async () => {
+ const {getByLabelText, getByTestId} = renderWithContext(
+ ,
+ );
+
+ fireEvent.change(getByLabelText('Enter a name for this rule'), {target: {value: 'Test IP Filter 2'}});
+ fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: '10.0.0.0/8'}});
+ fireEvent.click(getByTestId('save-add-edit-button'));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith({
+ cidr_block: '10.0.0.0/8',
+ description: 'Test IP Filter 2',
+ enabled: true,
+ owner_id: '',
+ }, existingRange);
+ expect(onExited).toHaveBeenCalled();
+ });
+ });
+
+ test('calls the onSave function with the correct values when the Save button is clicked for a new IP filter', async () => {
+ const {getByLabelText, getByTestId} = renderWithContext(
+ ,
+ );
+
+ fireEvent.change(getByLabelText('Enter a name for this rule'), {target: {value: 'Test IP Filter 2'}});
+ fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: '10.0.0.0/8'}});
+ fireEvent.click(getByTestId('save-add-edit-button'));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith({
+ cidr_block: '10.0.0.0/8',
+ description: 'Test IP Filter 2',
+ enabled: true,
+ owner_id: '',
+ });
+ expect(onExited).toHaveBeenCalled();
+ });
+ });
+
+ test('displays an error message when an invalid CIDR is entered', async () => {
+ const {getByLabelText, getByTestId, getByText} = renderWithContext(
+ ,
+ );
+
+ fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: 'invalid-cidr'}});
+ fireEvent.blur(getByLabelText('Enter IP Range'));
+ fireEvent.click(getByTestId('save-add-edit-button'));
+
+ await waitFor(() => {
+ expect(getByText('Invalid CIDR address range')).toBeInTheDocument();
+ expect(onSave).not.toHaveBeenCalled();
+ expect(onExited).not.toHaveBeenCalled();
+ });
+ });
+
+ test('disables the Save button when an invalid CIDR is entered', () => {
+ const {getByLabelText, getByTestId} = renderWithContext(
+ ,
+ );
+
+ fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: 'invalid-cidr'}});
+ fireEvent.blur(getByLabelText('Enter IP Range'));
+
+ expect(getByTestId('save-add-edit-button')).toBeDisabled();
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.tsx b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.tsx
new file mode 100644
index 0000000000..d663cd68cf
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.tsx
@@ -0,0 +1,146 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useState} from 'react';
+import {Modal} from 'react-bootstrap';
+import {FormattedMessage, useIntl} from 'react-intl';
+
+import {InformationOutlineIcon} from '@mattermost/compass-icons/components';
+import type {AllowedIPRange} from '@mattermost/types/config';
+
+import ExternalLink from 'components/external_link';
+import type {CustomMessageInputType} from 'components/widgets/inputs/input/input';
+import Input from 'components/widgets/inputs/input/input';
+
+import './add_edit_ip_filter_modal.scss';
+import {validateCIDR} from './ip_filtering_utils';
+
+type Props = {
+ onExited: () => void;
+ onSave: (allowedIPRange: AllowedIPRange, oldIPRange?: AllowedIPRange) => void;
+ existingRange?: AllowedIPRange;
+ currentIP?: string;
+}
+
+export default function IPFilteringAddOrEditModal({onExited, onSave, existingRange, currentIP}: Props) {
+ const {formatMessage} = useIntl();
+ const [name, setName] = useState(existingRange?.description || '');
+ const [CIDR, setCIDR] = useState(existingRange?.cidr_block || '');
+
+ const [CIDRError, setCIDRError] = useState(null);
+
+ const handleSave = () => {
+ const allowedIPRange: AllowedIPRange = {
+ cidr_block: CIDR,
+ description: name,
+ enabled: true,
+ owner_id: '',
+ };
+
+ if (existingRange) {
+ onSave(allowedIPRange, existingRange);
+ } else {
+ onSave(allowedIPRange);
+ }
+
+ onExited();
+ };
+
+ const handleCIDRChange = (e: React.ChangeEvent) => {
+ const cidr = e.target.value;
+ setCIDR(cidr);
+ setCIDRError(null);
+ };
+
+ const validateCIDRInput = () => {
+ if (!validateCIDR(CIDR)) {
+ setCIDRError({type: 'error', value: 'Invalid CIDR address range'});
+ }
+ };
+
+ return (
+
+
+
+ {existingRange?.cidr_block ? formatMessage({id: 'admin.ip_filtering.edit_ip_filter', defaultMessage: 'Edit IP Filter'}) : formatMessage({id: 'admin.ip_filtering.add_ip_filter', defaultMessage: 'Add IP Filter'})}
+
+
+
+
+
+
+ {formatMessage({id: 'admin.ip_filtering.your_current_ip_is', defaultMessage: 'Your current IP address is {ip}'}, {ip: currentIP})}
+
+
+
+
+ {formatMessage({id: 'admin.ip_filtering.name', defaultMessage: 'Name'})}
+ setName(e.target.value)}
+ value={name}
+ placeholder={formatMessage({id: 'admin.ip_filtering.rule_name_placeholder', defaultMessage: 'Enter a name for this rule'})}
+ required={true}
+ useLegend={false}
+ />
+
+
{formatMessage({id: 'admin.ip_filtering.allow_following_range', defaultMessage: 'Allow the following range of IP Addresses'})}
+
+
+
+ More info'}
+ values={{
+ link: (msg) => (
+
+ {msg}
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.scss b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.scss
new file mode 100644
index 0000000000..e79d67d07c
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.scss
@@ -0,0 +1,109 @@
+.DeleteConfirmationModal {
+ .modal-dialog {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 600px;
+ border: 1 px solid rgba(var(--center-channel-color-rgb), 0.08);
+ margin: auto;
+ border-radius: 12px;
+ transform: translate(-50%, -50%) !important;
+ }
+
+ .modal-content {
+ border-radius: 12px;
+ }
+
+ .modal-header {
+ .close {
+ &:hover {
+ background-color:
+ rgba(
+ var(--center-channel-color-rgb),
+ 0.08
+ );
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+ }
+
+ &:active {
+ background-color: rgba(var(--button-bg-rgb), 0.08);
+ color: var(--button-bg);
+ }
+
+ top: 26px;
+ right: 26px;
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ color: rgba(var(--center-channel-color-rgb), 0.56) !important;
+ font-family:
+ 'Open Sans',
+ sans-serif;
+ font-size: 32px;
+ font-weight: 400;
+ }
+
+ .title {
+ font-family: Metropolis, sans-serif;
+ font-size: 22px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 28px;
+ text-align: left;
+ }
+
+ display: block;
+ min-height: 48px;
+ padding-top: 26px;
+ padding-bottom: 0;
+ padding-left: 32px;
+ border: 0;
+ background: var(--center-channel-bg) !important;
+ border-radius: 12px;
+ color: var(--center-channel-color);
+ }
+
+ .modal-body {
+ overflow: hidden;
+ width: 100%;
+ flex-direction: column;
+ padding-top: 24px;
+ padding-right: 32px;
+ padding-bottom: 26px;
+ padding-left: 32px;
+ color: var(--center-channel-color, #3f4350);
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px;
+ }
+
+ .modal-footer {
+ padding: 24px 32px;
+ border-top: none;
+ border-radius: 12px;
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 20px;
+
+ .btn-cancel {
+ padding: 10px 20px;
+ border: none;
+ background: var(--button-bg-8, rgba(28, 88, 217, 0.08));
+ border-radius: 4px;
+ color: var(--button-bg, #1c58d9);
+ }
+
+ .btn-delete {
+ padding: 10px 20px;
+ border: none;
+ margin-left: 8px;
+ background: var(--denim-status-do-not-disturb, #d24b4e);
+ border-radius: 4px;
+ color: var(--button-color, #fff);
+ }
+ }
+}
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.test.tsx
new file mode 100644
index 0000000000..ee1fe18c9a
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.test.tsx
@@ -0,0 +1,70 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {render, fireEvent, waitFor} from '@testing-library/react';
+import React from 'react';
+
+import DeleteConfirmationModal from './delete_confirmation';
+
+describe('DeleteConfirmationModal', () => {
+ const onExited = jest.fn();
+ const onConfirm = jest.fn();
+ const filterToDelete = {
+ cidr_block: '192.168.0.0/16',
+ description: 'Test IP Filter',
+ enabled: true,
+ owner_id: '',
+ };
+
+ const baseProps = {
+ onExited,
+ onConfirm,
+ filterToDelete,
+ };
+
+ test('renders the modal with the correct title', () => {
+ const {getByText} = render(
+ ,
+ );
+
+ expect(getByText('Delete IP Filter')).toBeInTheDocument();
+ });
+
+ test('renders the modal with the correct filter name in description', () => {
+ const {getByText} = render(
+ ,
+ );
+ expect(getByText('Test IP Filter')).toBeInTheDocument();
+ });
+
+ test('calls the onClose function when the Cancel button is clicked', () => {
+ const {getByText} = render(
+ ,
+ );
+
+ fireEvent.click(getByText('Cancel'));
+
+ expect(onExited).toHaveBeenCalled();
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ test('calls the onConfirm function with the correct filter when the Delete filter button is clicked', async () => {
+ const {getByText} = render(
+ ,
+ );
+
+ fireEvent.click(getByText('Delete filter'));
+
+ await waitFor(() => {
+ expect(onConfirm).toHaveBeenCalledWith(filterToDelete);
+ });
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.tsx b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.tsx
new file mode 100644
index 0000000000..f7f7e6020c
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.tsx
@@ -0,0 +1,59 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {Modal} from 'react-bootstrap';
+import {useIntl} from 'react-intl';
+
+import type {AllowedIPRange} from '@mattermost/types/config';
+
+import './delete_confirmation.scss';
+
+type Props = {
+ onExited: () => void;
+ onConfirm?: (filter: AllowedIPRange) => void;
+ filterToDelete?: AllowedIPRange;
+}
+
+export default function DeleteConfirmationModal({onExited, onConfirm, filterToDelete}: Props) {
+ const {formatMessage} = useIntl();
+ return (
+
+
+
+ {formatMessage({id: 'admin.ip_filtering.delete_confirmation_title', defaultMessage: 'Delete IP Filter'})}
+
+
+
+ {formatMessage({
+ id: 'admin.ip_filtering.delete_confirmation_body',
+ defaultMessage: 'Are you sure you want to delete IP filter {filter}? Users with IP addresses outside of this range won\'t be able to access the workspace when IP Filtering is enabled',
+ },
+ {filter: ({filterToDelete?.description})},
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section.test.tsx
new file mode 100644
index 0000000000..eabd7af23a
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section.test.tsx
@@ -0,0 +1,122 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {screen, fireEvent} from '@testing-library/react';
+import React from 'react';
+
+import type {AllowedIPRange} from '@mattermost/types/config';
+
+import {renderWithContext} from 'tests/react_testing_utils';
+
+import EditSection from './';
+
+describe('EditSection', () => {
+ const ipFilters = [
+ {
+ cidr_block: '192.168.0.0/24',
+ description: 'Test Filter',
+ },
+ ] as AllowedIPRange[];
+ const currentUsersIP = '192.168.0.1';
+ const setShowAddModal = jest.fn();
+ const setEditFilter = jest.fn();
+ const handleConfirmDeleteFilter = jest.fn();
+ const currentIPIsInRange = true;
+
+ const baseProps = {
+ ipFilters,
+ currentUsersIP,
+ setShowAddModal,
+ setEditFilter,
+ handleConfirmDeleteFilter,
+ currentIPIsInRange,
+ };
+
+ test('renders the component', () => {
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText('Allowed IP Addresses')).toBeInTheDocument();
+ expect(screen.getByText('Create rules to allow access to the workspace for specified IP addresses only.')).toBeInTheDocument();
+ expect(screen.getByText('If no rules are added, all IP addresses will be allowed.')).toBeInTheDocument();
+ expect(screen.getByText('Add Filter')).toBeInTheDocument();
+ expect(screen.getByText('Filter Name')).toBeInTheDocument();
+ expect(screen.getByText('IP Address Range')).toBeInTheDocument();
+ expect(screen.getByText('Test Filter')).toBeInTheDocument();
+ expect(screen.getByText('192.168.0.0/24')).toBeInTheDocument();
+ });
+
+ test('clicking the Add Filter button calls setShowAddModal', () => {
+ renderWithContext(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('Add Filter'));
+
+ expect(setShowAddModal).toHaveBeenCalledTimes(1);
+ expect(setShowAddModal).toHaveBeenCalledWith(true);
+ });
+
+ test('clicking the Edit button calls setEditFilter', () => {
+ renderWithContext(
+ ,
+ );
+
+ fireEvent.mouseEnter(screen.getByText('Test Filter'));
+ fireEvent.click(screen.getByRole('button', {
+ name: /Edit/i,
+ }));
+
+ expect(setEditFilter).toHaveBeenCalledTimes(1);
+ expect(setEditFilter).toHaveBeenCalledWith(ipFilters[0]);
+ });
+
+ test('clicking the Delete button calls handleConfirmDeleteFilter', () => {
+ renderWithContext(
+ ,
+ );
+
+ fireEvent.mouseEnter(screen.getByText('Test Filter'));
+ fireEvent.click(screen.getByRole('button', {
+ name: /Delete/i,
+ }));
+
+ expect(handleConfirmDeleteFilter).toHaveBeenCalledTimes(1);
+ expect(handleConfirmDeleteFilter).toHaveBeenCalledWith(ipFilters[0]);
+ });
+
+ test('displays an error panel if current IP is not in range', () => {
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText('Your IP address 192.168.1.1 is not included in your allowed IP address rules.')).toBeInTheDocument();
+ expect(screen.getByText('Include your IP address in at least one of the rules below to continue.')).toBeInTheDocument();
+ expect(screen.getByText('Add your IP address')).toBeInTheDocument();
+ });
+
+ test('displays a message if no filters are added', () => {
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText('No IP filtering rules added')).toBeInTheDocument();
+ expect(screen.getByText('Add a filter')).toBeInTheDocument();
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_edit_table_row.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_edit_table_row.tsx
new file mode 100644
index 0000000000..14343bbe2e
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_edit_table_row.tsx
@@ -0,0 +1,85 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {useIntl} from 'react-intl';
+
+import {
+ PencilOutlineIcon,
+ TrashCanOutlineIcon,
+} from '@mattermost/compass-icons/components';
+import type {AllowedIPRange} from '@mattermost/types/config';
+
+import OverlayTrigger from 'components/overlay_trigger';
+import Tooltip from 'components/tooltip';
+
+type EditTableRowProps = {
+ allowedIPRange: AllowedIPRange;
+ index: number;
+ handleRowMouseEnter: (index: number) => void;
+ handleRowMouseLeave: () => void;
+ setEditFilter: (filter: AllowedIPRange) => void;
+ handleConfirmDeleteFilter: (filter: AllowedIPRange) => void;
+ hoveredRow: number | null;
+};
+
+const EditTableRow = ({
+ allowedIPRange,
+ index,
+ handleRowMouseEnter,
+ handleRowMouseLeave,
+ setEditFilter,
+ handleConfirmDeleteFilter,
+ hoveredRow,
+}: EditTableRowProps) => {
+ const {formatMessage} = useIntl();
+ const editTooltip = {formatMessage({id: 'admin.ip_filtering.edit', defaultMessage: 'Edit'})};
+ const deleteTooltip = {formatMessage({id: 'admin.ip_filtering.delete', defaultMessage: 'Delete'})};
+ return (
+ handleRowMouseEnter(index)}
+ onMouseLeave={handleRowMouseLeave}
+ >
+
{allowedIPRange.description}
+
{allowedIPRange.cidr_block}
+
+ {hoveredRow === index && (
+ <>
+
+ setEditFilter(allowedIPRange)}
+ >
+
+
+
+
+ handleConfirmDeleteFilter(allowedIPRange)}
+ >
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default EditTableRow;
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_header.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_header.tsx
new file mode 100644
index 0000000000..80be7a7611
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_header.tsx
@@ -0,0 +1,72 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import IPNotInRangeErrorPanel from './edit_section_ip_not_in_range_panel';
+
+type EditSectionHeaderProps = {
+ setShowAddModal: (show: boolean) => void;
+ currentIPIsInRange: boolean;
+ currentUsersIP: string | null;
+};
+
+const EditSectionHeader = ({
+ setShowAddModal,
+ currentIPIsInRange,
+ currentUsersIP,
+}: EditSectionHeaderProps) => (
+
+
+
+
+
+
+
+
+
+
+
+ {msg},
+ }}
+ />
+
+
+
+
+
+
+ {
+ !currentIPIsInRange &&
+
+ }
+
+
+);
+
+export default EditSectionHeader;
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_ip_not_in_range_panel.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_ip_not_in_range_panel.tsx
new file mode 100644
index 0000000000..ffac24d166
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_ip_not_in_range_panel.tsx
@@ -0,0 +1,49 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import {AlertOutlineIcon} from '@mattermost/compass-icons/components';
+
+type IPNotInRangeErrorPanelProps = {
+ currentUsersIP: string | null;
+ setShowAddModal: (show: boolean) => void;
+};
+
+const IPNotInRangeErrorPanel = ({
+ currentUsersIP,
+ setShowAddModal,
+}: IPNotInRangeErrorPanelProps) => (
+
+
+
+
+
+
+
+
+
setShowAddModal(true)}
+ >
+
+
+
+
+
+);
+
+export default IPNotInRangeErrorPanel;
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx
new file mode 100644
index 0000000000..425ac25d91
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx
@@ -0,0 +1,46 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import IPFilteringEarthSvg from 'components/common/svg_images_components/ip_filtering_earth_svg';
+
+type NoFiltersPanelProps = {
+ setShowAddModal: (show: boolean) => void;
+};
+
+const NoFiltersPanel = ({setShowAddModal}: NoFiltersPanelProps) => (
+
+
+
+
+
+
+
+
+
(
+ setShowAddModal(true)}
+ className='Button'
+ >
+ {msg}
+
+ ),
+ }}
+ />
+
+
+);
+
+export default NoFiltersPanel;
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_sections.scss b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_sections.scss
new file mode 100644
index 0000000000..92b9da5c16
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_sections.scss
@@ -0,0 +1,298 @@
+.EditSection {
+ width: 920px;
+ height: auto;
+ max-height: 100%;
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
+ background: var(--center-channel-bg);
+ border-radius: 4px;
+ box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08);
+
+ .TableSectionContent {
+ display: inline-flex;
+ width: 100%;
+ flex-direction: column;
+ padding: 20px;
+
+ .Table {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+ justify-content: flex-start;
+
+ .HeaderRow {
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--center-channel-color-16, rgba(63, 67, 80, 0.16));
+ background-color: #f5f5f5;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ font-weight: 600;
+ gap: 10px;
+ line-height: 20px;
+
+ .FilterName,
+ .IpAddressRange {
+ color: #3f4350;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ line-height: 20px;
+ word-wrap: break-word;
+ }
+
+ .FilterName {
+ width: 291px;
+ font-weight: 600;
+ }
+
+ .IpAddressRange {
+ flex: 1 1 0;
+ }
+ }
+
+ .Row {
+ display: inline-flex;
+ height: 40px;
+ align-items: center;
+ align-self: stretch;
+ justify-content: flex-start;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--center-channel-color-8, rgba(63, 67, 80, 0.08));
+ background: #fff;
+ gap: 10px;
+
+ .FilterName,
+ .IpAddressRange {
+ color: #3f4350;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ line-height: 20px;
+ word-wrap: break-word;
+ }
+
+ .FilterName {
+ width: 291px;
+ }
+
+ .IpAddressRange {
+ flex: 1 1 0;
+ }
+
+ .Actions {
+ // height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ >div {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px;
+ border-radius: 4px;
+
+ &:hover {
+ background: var(--center-channel-color-8, rgba(63, 67, 80, 0.08));
+ }
+
+ &.edit {
+ color: var(--center-channel-color-72, rgba(63, 67, 80, 0.72));
+ }
+
+ &.delete {
+ color: var(--error-text, rgba(210, 75, 78, 1));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .NoFilters {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 32px 20px;
+
+ .Title {
+ color: var(--center-channel-color, #3f4350);
+ font-family: Metropolis;
+ font-size: 20px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 28px;
+ text-align: center;
+ }
+
+ .Subtitle {
+ max-width: 320px;
+ margin-top: 8px;
+ color: var(--center-channel-color-72, rgba(63, 67, 80, 0.72));
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px;
+ text-align: center;
+
+ .Button {
+ display: inline;
+ height: 20px;
+ padding: 0;
+ background: none;
+ color: var(--link-color, #1c58d9);
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px;
+ text-align: center;
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ .AllowedIPAddressesSection {
+ display: inline-flex;
+ width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+
+ .SectionHeaderContent {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+ justify-content: flex-start;
+ padding: 24px 32px;
+ border-bottom: 1px solid rgba(63, 67, 80, 0.12);
+ gap: 24px;
+
+ .HeaderContent {
+ display: inline-flex;
+ align-items: flex-start;
+ align-self: stretch;
+ justify-content: flex-start;
+ gap: 32px;
+
+ .TitleSubtitle {
+ display: inline-flex;
+ flex: 1 1 0;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+
+ .Title {
+ color: #3f4350;
+ font-family: Metropolis;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 24px;
+ word-wrap: break-word;
+ }
+
+ .Subtitle {
+ align-self: stretch;
+ color: #3f4350;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 20px;
+ word-wrap: break-word;
+ }
+ }
+
+ .AddIPFilterButton {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-end;
+ justify-content: flex-start;
+ border: none;
+ background: none;
+ gap: 16px;
+
+ .Button {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 10px 16px;
+ border: none;
+ background: #1c58d9;
+ border-radius: 4px;
+ color: #fff;
+ gap: 10px;
+ }
+ }
+ }
+
+ .NotInRangeErrorPanel {
+ display: inline-flex;
+ width: 856px;
+ justify-content: flex-start;
+ padding: 16px;
+ border: 1px solid var(--error-text-16, rgba(210, 75, 78, 0.16));
+ background: var(--error-text-8, rgba(210, 75, 78, 0.08));
+ border-radius: 4px;
+ gap: 12px;
+
+ .Icon {
+ color: var(--error-text, #d24b4e);
+ }
+
+ .Content {
+ display: inline-flex;
+ flex: 1 1 0;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding-right: 24px;
+ gap: 8px;
+
+ .Title {
+ align-self: stretch;
+ color: #3f4350;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 20px;
+ word-wrap: break-word;
+ }
+
+ .Body {
+ align-self: stretch;
+ color: #3f4350;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 20px;
+ word-wrap: break-word;
+ }
+
+ .Button {
+ display: inline;
+ height: 16px;
+ padding: 0;
+ padding-left: 2px;
+ background: none;
+ color: var(--button-bg, rgba(28, 88, 217, 1));
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 20px;
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/index.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/index.tsx
new file mode 100644
index 0000000000..7b53b2f676
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/index.tsx
@@ -0,0 +1,78 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useState} from 'react';
+import {useIntl} from 'react-intl';
+
+import type {AllowedIPRange} from '@mattermost/types/config';
+
+import EditTableRow from './edit_section_edit_table_row';
+import EditSectionHeader from './edit_section_header';
+import NoFiltersPanel from './edit_section_no_filters_panel';
+
+import './edit_sections.scss';
+
+type EditSectionProps = {
+ ipFilters: AllowedIPRange[] | null;
+ currentUsersIP: string | null;
+ currentIPIsInRange: boolean;
+ setShowAddModal: (show: boolean) => void;
+ setEditFilter: (filter: AllowedIPRange) => void;
+ handleConfirmDeleteFilter: (filter: AllowedIPRange) => void;
+};
+
+const EditSection = ({
+ ipFilters,
+ currentUsersIP,
+ setShowAddModal,
+ setEditFilter,
+ handleConfirmDeleteFilter,
+ currentIPIsInRange,
+}: EditSectionProps) => {
+ const {formatMessage} = useIntl();
+ const [hoveredRow, setHoveredRow] = useState(null);
+ return (
+
+
+ {Boolean(ipFilters?.length) && (
+
+
+
+
+ {formatMessage({
+ id: 'admin.ip_filtering.filter_name',
+ defaultMessage: 'Filter Name',
+ })}
+
+
+ {formatMessage({
+ id: 'admin.ip_filtering.ip_address_range',
+ defaultMessage: 'IP Address Range',
+ })}
+
+
+ {ipFilters?.map((allowedIPRange, index) => (
+
setHoveredRow(index)}
+ handleRowMouseLeave={() => setHoveredRow(null)}
+ setEditFilter={setEditFilter}
+ handleConfirmDeleteFilter={handleConfirmDeleteFilter}
+ hoveredRow={hoveredRow}
+ />
+ ))}
+
+
+ )}
+ {ipFilters?.length === 0 &&
}
+
+ );
+};
+
+export default EditSection;
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/enable_section.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/enable_section.test.tsx
new file mode 100644
index 0000000000..8c68e0e16f
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/enable_section.test.tsx
@@ -0,0 +1,65 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {screen, fireEvent} from '@testing-library/react';
+import React from 'react';
+
+import {renderWithContext} from 'tests/react_testing_utils';
+
+import EnableSectionContent from './enable_section';
+jest.mock('components/external_link', () => {
+ return jest.fn().mockImplementation(({children, ...props}) => {
+ return {children};
+ });
+});
+
+describe('EnableSectionContent', () => {
+ const filterToggle = true;
+ const setFilterToggle = jest.fn();
+
+ const baseProps = {
+ filterToggle,
+ setFilterToggle,
+ };
+
+ test('renders the component', () => {
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText('Enable IP Filtering')).toBeInTheDocument();
+ expect(screen.getByText('Limit access to your workspace by IP address.')).toBeInTheDocument();
+ expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument();
+ expect(screen.getByRole('button', {pressed: true})).toBeInTheDocument();
+ });
+
+ test('clicking the toggle calls setFilterToggle', () => {
+ renderWithContext(
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId('filterToggle-button'));
+
+ expect(setFilterToggle).toHaveBeenCalledTimes(1);
+ expect(setFilterToggle).toHaveBeenCalledWith(false);
+ });
+
+ test('renders the component, with toggle not pressed if filterToggle is false', () => {
+ renderWithContext(
+ ,
+ );
+
+ expect(screen.getByText('Enable IP Filtering')).toBeInTheDocument();
+ expect(screen.getByText('Limit access to your workspace by IP address.')).toBeInTheDocument();
+ expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument();
+ expect(screen.getByRole('button', {pressed: false})).toBeInTheDocument();
+ });
+});
+
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/enable_section.tsx b/webapp/channels/src/components/admin_console/ip_filtering/enable_section.tsx
new file mode 100644
index 0000000000..90d43374a6
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/enable_section.tsx
@@ -0,0 +1,57 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+
+import ExternalLink from 'components/external_link';
+import Toggle from 'components/toggle';
+
+type Props = {
+ filterToggle: boolean;
+ setFilterToggle: (value: boolean) => void;
+};
+
+const EnableSectionContent: React.FC = ({filterToggle, setFilterToggle}) => {
+ const {formatMessage} = useIntl();
+
+ return (
+
+
+
+
+ {formatMessage({id: 'admin.ip_filtering.enable_ip_filtering', defaultMessage: 'Enable IP Filtering'})}
+
+
+ Learn more in the docs'}
+ values={{
+ learnmore: (msg) => (
+
+ {msg}
+
+ ),
+ }}
+ />
+
+
+
+ setFilterToggle(!filterToggle)}
+ toggled={filterToggle}
+ toggleClassName='btn-toggle-primary'
+ />
+
+
+
+ );
+};
+
+export default EnableSectionContent;
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/index.tsx b/webapp/channels/src/components/admin_console/ip_filtering/index.tsx
new file mode 100644
index 0000000000..1669af592a
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/index.tsx
@@ -0,0 +1,269 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useEffect, useState} from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+import {useDispatch} from 'react-redux';
+
+import {AlertOutlineIcon} from '@mattermost/compass-icons/components';
+import type {AllowedIPRange, FetchIPResponse} from '@mattermost/types/config';
+
+import {applyIPFilters, getCurrentIP, getIPFilters} from 'actions/admin_actions';
+import {closeModal, openModal} from 'actions/views/modals';
+
+import AdminHeader from 'components/widgets/admin_console/admin_header';
+
+import {ModalIdentifiers} from 'utils/constants';
+
+import IPFilteringAddOrEditModal from './add_edit_ip_filter_modal';
+import DeleteConfirmationModal from './delete_confirmation';
+import EditSection from './edit_section';
+import EnableSectionContent from './enable_section';
+import {isIPAddressInRanges} from './ip_filtering_utils';
+import SaveConfirmationModal from './save_confirmation_modal';
+
+import SaveChangesPanel from '../team_channel_settings/save_changes_panel';
+
+import './ip_filtering.scss';
+
+const IPFiltering = () => {
+ const dispatch = useDispatch();
+ const {formatMessage} = useIntl();
+ const [ipFilters, setIpFilters] = useState(null);
+ const [originalIpFilters, setOriginalIpFilters] = useState(null);
+ const [saveNeeded, setSaveNeeded] = useState(false);
+ const [currentUsersIP, setCurrentUsersIP] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [filterToggle, setFilterToggle] = useState(false);
+
+ useEffect(() => {
+ getIPFilters((data: AllowedIPRange[]) => {
+ setIpFilters(data);
+ setOriginalIpFilters(data);
+ });
+
+ getCurrentIP((res: FetchIPResponse) => {
+ setCurrentUsersIP(res.ip);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (ipFilters === null || originalIpFilters === null) {
+ return;
+ }
+
+ // Check if the ipFilters list differs from the originalIpFilters list
+ const haveFiltersChanged = JSON.stringify(ipFilters) !== JSON.stringify(originalIpFilters);
+ setSaveNeeded(haveFiltersChanged);
+ }, [ipFilters, originalIpFilters]);
+
+ const currentIPIsInRange = () => {
+ if (!filterToggle) {
+ return true;
+ }
+ if (!ipFilters?.length) {
+ return true;
+ }
+ return ipFilters !== null && currentUsersIP !== null && isIPAddressInRanges(currentUsersIP, ipFilters);
+ };
+
+ useEffect(() => {
+ if (!ipFilters?.length) {
+ return;
+ }
+ setFilterToggle(ipFilters?.some((filter: AllowedIPRange) => filter.enabled === true) ?? false);
+ }, [ipFilters]);
+
+ useEffect(() => {
+ if (filterToggle === false) {
+ setIpFilters(ipFilters?.map((filter: AllowedIPRange): AllowedIPRange => {
+ return {
+ ...filter,
+ enabled: false,
+ };
+ }) || []);
+ } else {
+ setIpFilters(ipFilters?.map((filter: AllowedIPRange): AllowedIPRange => {
+ return {
+ ...filter,
+ enabled: true,
+ };
+ }) || []);
+ }
+ }, [filterToggle]);
+
+ function handleEditFilter(filter: AllowedIPRange, existingRange?: AllowedIPRange) {
+ setIpFilters((prevIpFilters) => {
+ if (!prevIpFilters) {
+ return [filter];
+ }
+ const index = prevIpFilters.findIndex((f) => f.cidr_block === existingRange?.cidr_block);
+ if (index === -1) {
+ return null;
+ }
+ const updatedFilters = [...prevIpFilters];
+ updatedFilters[index] = filter;
+ return updatedFilters;
+ });
+ setSaveNeeded(true);
+ }
+
+ function showAddModal() {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.IP_FILTERING_ADD_EDIT_MODAL,
+ dialogType: IPFilteringAddOrEditModal,
+ dialogProps: {
+ currentIP: currentUsersIP!,
+ onSave: handleAddFilter,
+ },
+ }));
+ }
+
+ function showEditModal(editFilter: AllowedIPRange) {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.IP_FILTERING_ADD_EDIT_MODAL,
+ dialogType: IPFilteringAddOrEditModal,
+ dialogProps: {
+ currentIP: currentUsersIP!,
+ onSave: handleEditFilter,
+ existingRange: editFilter!,
+ },
+ }));
+ }
+
+ function showConfirmDeleteFilterModal(filter: AllowedIPRange) {
+ dispatch(openModal({
+ modalId: ModalIdentifiers.IP_FILTERING_DELETE_CONFIRMATION_MODAL,
+ dialogType: DeleteConfirmationModal,
+ dialogProps: {
+ onConfirm: handleDeleteFilter,
+ filterToDelete: filter,
+ },
+ }));
+ }
+
+ function handleDeleteFilter(filter: AllowedIPRange) {
+ dispatch(closeModal(ModalIdentifiers.IP_FILTERING_DELETE_CONFIRMATION_MODAL));
+ setIpFilters((prevIpFilters) => prevIpFilters?.filter((f) => f.cidr_block !== filter.cidr_block) ?? null);
+ setSaveNeeded(true);
+ }
+
+ function handleAddFilter(filter: AllowedIPRange) {
+ dispatch(closeModal(ModalIdentifiers.IP_FILTERING_ADD_EDIT_MODAL));
+ setIpFilters((prevIpFilters) => [...(prevIpFilters ?? []), filter]);
+ setSaveNeeded(true);
+ }
+
+ function handleSave() {
+ setSaving(true);
+ dispatch(closeModal(ModalIdentifiers.IP_FILTERING_SAVE_CONFIRMATION_MODAL));
+
+ const success = (data: AllowedIPRange[]) => {
+ setIpFilters(data);
+ setSaving(false);
+ setSaveNeeded(false);
+ };
+
+ applyIPFilters(ipFilters ?? [], success);
+ }
+
+ function handleSaveClick() {
+ const saveConfirmModalProps = {
+ onConfirm: handleSave,
+ } as any;
+ if (!ipFilters?.length && filterToggle) {
+ saveConfirmModalProps.title = formatMessage({id: 'admin.ip_filtering.apply_ip_filter_changes', defaultMessage: 'Apply IP Filter Changes'});
+ saveConfirmModalProps.subtitle = (
+ all IP addresses will have access to the workspace.'}
+ values={{
+ strong: (content: string) => {content},
+ }}
+ />
+ );
+ saveConfirmModalProps.buttonText = formatMessage({id: 'admin.ip_filtering.apply_changes', defaultMessage: 'Yes, apply changes'});
+ saveConfirmModalProps.includeDisclaimer = false;
+ } else if ((ipFilters?.length && !filterToggle) || (!ipFilters?.length && !filterToggle)) {
+ saveConfirmModalProps.title = formatMessage({id: 'admin.ip_filtering.disable_ip_filtering', defaultMessage: 'Disable IP Filtering'});
+ saveConfirmModalProps.subtitle = (
+ All IP addresses will have access to the workspace.'}
+ values={{
+ strong: (content: string) => {content},
+ }}
+ />
+ );
+ saveConfirmModalProps.buttonText = formatMessage({id: 'admin.ip_filtering.yes_disable_ip_filtering', defaultMessage: 'Yes, disable IP Filtering'});
+ saveConfirmModalProps.includeDisclaimer = false;
+ } else {
+ saveConfirmModalProps.title = formatMessage({id: 'admin.ip_filtering.apply_ip_filter_changes', defaultMessage: 'Apply IP Filter Changes'});
+ saveConfirmModalProps.subtitle = (
+ Users with IP addresses outside of the IP ranges provided will no longer have access to the workspace.'}
+ values={{
+ strong: (content: string) => {content},
+ }}
+ />
+ );
+ saveConfirmModalProps.buttonText = formatMessage({id: 'admin.ip_filtering.apply_changes', defaultMessage: 'Yes, apply changes'});
+ saveConfirmModalProps.includeDisclaimer = true;
+ }
+
+ dispatch(openModal({
+ modalId: ModalIdentifiers.IP_FILTERING_SAVE_CONFIRMATION_MODAL,
+ dialogType: SaveConfirmationModal,
+ dialogProps: saveConfirmModalProps,
+ }));
+ }
+
+ const saveBarError = () => {
+ if (currentIPIsInRange()) {
+ return undefined;
+ }
+
+ return (
+ <>
+ {formatMessage({id: 'admin.ip_filtering.error_on_page', defaultMessage: 'Your IP address is not included in your filters'})}
+ >
+ );
+ };
+
+ return (
+
+
+ {formatMessage({id: 'admin.ip_filtering.ip_filtering', defaultMessage: 'IP Filtering'})}
+
+
+ <>
+
+ {ipFilters !== null && currentUsersIP !== null && filterToggle &&
+
+ }
+ >
+
+
+
+ );
+};
+
+export default 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
new file mode 100644
index 0000000000..8f2ea0bc9e
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss
@@ -0,0 +1,82 @@
+.IPFiltering {
+ height: 100%;
+
+ .MainPanel {
+ display: flex;
+ height: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 20px;
+ gap: 20px;
+
+ .EnableSectionContent {
+ display: inline-flex;
+ width: 920px;
+ height: 92px;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding: 24px 32px;
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
+ background-color: var(--center-channel-bg);
+ border-radius: 4px;
+ box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08);
+ gap: 24px;
+
+ .TitleSubtitleContent {
+ display: inline-flex;
+ align-items: flex-start;
+ align-self: stretch;
+ justify-content: flex-start;
+ gap: 32px;
+
+ .TitleSubtitle {
+ flex: 1 1 0;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 24px;
+
+ .Title {
+ color: #3f4350;
+ font-family: Metropolis;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 24px;
+ word-wrap: break-word;
+ }
+
+ .Subtitle {
+ align-self: stretch;
+ color: #3f4350;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 20px;
+ word-wrap: break-word;
+ }
+ }
+
+ .SwitchSelector {
+ width: 32px;
+ height: 20px;
+ flex-shrink: 0;
+ }
+ }
+ }
+ }
+
+ .error-message {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--error-text, #d24b4e);
+ font-size: 14px;
+ line-height: 20px;
+
+ >svg {
+ margin-right: 7px;
+ margin-left: 7px;
+ }
+ }
+}
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
new file mode 100644
index 0000000000..bdb31c8183
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx
@@ -0,0 +1,204 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {render, fireEvent, waitFor, screen} from '@testing-library/react';
+import React from 'react';
+import {IntlProvider} from 'react-intl';
+import {Provider} from 'react-redux';
+import {BrowserRouter as Router} from 'react-router-dom';
+
+import type {AllowedIPRange, FetchIPResponse} from '@mattermost/types/config';
+
+import {Client4} from 'mattermost-redux/client';
+
+import configureStore from 'store';
+
+import ModalController from 'components/modal_controller';
+
+import IPFiltering from './index';
+
+jest.mock('mattermost-redux/client');
+
+describe('IPFiltering', () => {
+ const ipFilters = [
+ {
+ cidr_block: '10.0.0.0/8',
+ description: 'Test IP Filter',
+ enabled: true,
+ },
+ ] as AllowedIPRange[];
+
+ const intlProviderProps = {
+ defaultLocale: 'en',
+ locale: 'en',
+ };
+ const currentIP = '10.0.0.1';
+ const applyIPFiltersMock = jest.fn(() => Promise.resolve(ipFilters));
+ const getIPFiltersMock = jest.fn(() => Promise.resolve(ipFilters));
+ const getCurrentIPMock = jest.fn(() => Promise.resolve({ip: currentIP} as FetchIPResponse));
+
+ beforeEach(() => {
+ Client4.applyIPFilters = applyIPFiltersMock;
+ Client4.getIPFilters = getIPFiltersMock;
+ Client4.getCurrentIP = getCurrentIPMock;
+ });
+
+ const mockedStore = configureStore({
+ entities: {
+ users: {
+ currentUserId: 'current_user_id',
+ },
+ general: {
+ config: {},
+ license: {},
+ },
+ },
+ views: {
+ admin: {
+ navigationBlock: {
+ blocked: false,
+ },
+ },
+ },
+ });
+
+ const wrapWithIntlProviderAndStore = (component: JSX.Element) => (
+
+
+
+
+ {component}
+
+
+
+ );
+
+ test('renders the IP Filtering page', async () => {
+ const {getByText} = render(wrapWithIntlProviderAndStore());
+
+ expect(getByText('IP Filtering')).toBeInTheDocument();
+ expect(getByText('Enable IP Filtering')).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(getByText('Add Filter')).toBeInTheDocument();
+ expect(getByText('Test IP Filter')).toBeInTheDocument();
+ expect(getByText('10.0.0.0/8')).toBeInTheDocument();
+ });
+
+ expect(getByText('Save')).toBeInTheDocument();
+ });
+
+ test('disables IP Filtering when the toggle is turned off', async () => {
+ 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();
+ });
+ });
+
+ test('adds a new IP filter when the "Add IP Filter" button is clicked', async () => {
+ const {getByLabelText, getByText} = render(wrapWithIntlProviderAndStore());
+
+ await waitFor(() => {
+ expect(getByText('Add Filter')).toBeInTheDocument();
+ });
+
+ fireEvent.click(getByText('Add Filter'));
+
+ 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: 'Test IP Filter 2'}});
+ fireEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(getByText('Test IP Filter 2')).toBeInTheDocument();
+ expect(getByText('192.168.0.0/16')).toBeInTheDocument();
+ });
+ });
+
+ test('edits an existing IP filter when the "Edit" button is clicked', async () => {
+ const {getByLabelText, getByText, queryByText} = 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();
+ });
+ });
+
+ test('deletes an existing IP filter when the "Delete" button is clicked', async () => {
+ const {getByText, queryByText} = render(wrapWithIntlProviderAndStore());
+
+ await waitFor(() => {
+ expect(getByText('Test IP Filter')).toBeInTheDocument();
+ });
+
+ fireEvent.mouseEnter(screen.getByText('Test IP Filter'));
+ fireEvent.click(screen.getByRole('button', {
+ name: /Delete/i,
+ }));
+
+ const confirmButton = getByText('Delete filter');
+
+ fireEvent.click(confirmButton);
+
+ await waitFor(() => {
+ expect(queryByText('Test IP Filter')).not.toBeInTheDocument();
+ });
+ });
+
+ test('saves changes when the "Save" button is clicked', async () => {
+ 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();
+ });
+
+ fireEvent.click(getByText('Save'));
+ fireEvent.click(screen.getByTestId('save-confirmation-button'));
+
+ await waitFor(() => {
+ expect(applyIPFiltersMock).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.test.ts b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.test.ts
new file mode 100644
index 0000000000..7ff8fe0e98
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.test.ts
@@ -0,0 +1,99 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {AllowedIPRange} from '@mattermost/types/config';
+
+import {isIPAddressInRanges, validateCIDR} from './ip_filtering_utils';
+
+describe('isIPAddressInRanges', () => {
+ const allowedIPRanges = [
+ {
+ cidr_block: '192.168.0.0/24',
+ description: 'Test Filter',
+ },
+ {
+ cidr_block: '10.1.0.0/16',
+ description: 'Test Filter 2',
+ },
+ {
+ cidr_block: '172.16.0.0/12',
+ description: 'Test Filter 3',
+ },
+ {
+ cidr_block: '2001:db8::/32',
+ description: 'Test Filter 4',
+ },
+ {
+ cidr_block: 'fe80::/10',
+ description: 'Test Filter 5',
+ },
+ ] as AllowedIPRange[];
+
+ test('returns true if the IPv4 address is within an allowed IP range', () => {
+ expect(isIPAddressInRanges('192.168.0.1', allowedIPRanges)).toBe(true);
+ expect(isIPAddressInRanges('10.1.0.1', allowedIPRanges)).toBe(true);
+ expect(isIPAddressInRanges('172.16.0.1', allowedIPRanges)).toBe(true);
+ expect(isIPAddressInRanges('172.31.255.255', allowedIPRanges)).toBe(true);
+ });
+
+ test('returns false if the IPv4 address is not within an allowed IP range', () => {
+ expect(isIPAddressInRanges('192.168.1.1', allowedIPRanges)).toBe(false);
+ expect(isIPAddressInRanges('172.15.255.255', allowedIPRanges)).toBe(false);
+ expect(isIPAddressInRanges('172.32.0.1', allowedIPRanges)).toBe(false);
+ expect(isIPAddressInRanges('10.0.55.8', allowedIPRanges)).toBe(false);
+ });
+
+ test('returns true if the IPv6 address is within an allowed IP range', () => {
+ expect(isIPAddressInRanges('2001:db8::1', allowedIPRanges)).toBe(true);
+ expect(isIPAddressInRanges('fe80::1', allowedIPRanges)).toBe(true);
+ expect(isIPAddressInRanges('2001:db8:1234:5678::abcd', allowedIPRanges)).toBe(true);
+ });
+
+ test('returns false if the IPv6 address is not within an allowed IP range', () => {
+ expect(isIPAddressInRanges('3001::1234:5678:abcd:ef02', allowedIPRanges)).toBe(false);
+ expect(isIPAddressInRanges('ff80:db8:1234:5678::abce', allowedIPRanges)).toBe(false);
+ });
+});
+
+describe('validateCIDR', () => {
+ const goodRanges = [
+ {
+ cidr_block: '192.168.0.0/24',
+ description: 'Test Filter',
+ },
+ {
+ cidr_block: '10.1.0.0/16',
+ description: 'Test Filter 2',
+ },
+ {
+ cidr_block: '172.16.0.0/12',
+ description: 'Test Filter 3',
+ },
+ {
+ cidr_block: '2001:db8::/32',
+ description: 'Test Filter 4',
+ },
+ {
+ cidr_block: 'fe80::/10',
+ description: 'Test Filter 5',
+ },
+ ] as AllowedIPRange[];
+
+ const badRanges = [
+ {
+ cidr_block: 'fe80::1234:5678:abcd:ef01:/8',
+ },
+ ];
+
+ test('returns true for valid CIDR blocks', () => {
+ for (const allowedIPRange of goodRanges) {
+ expect(validateCIDR(allowedIPRange.cidr_block)).toBeTruthy();
+ }
+ });
+
+ test('returns false for invalid CIDR blocks', () => {
+ for (const allowedIPRange of badRanges) {
+ expect(validateCIDR(allowedIPRange.cidr_block)).not.toBeTruthy();
+ }
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.ts b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.ts
new file mode 100644
index 0000000000..c7b4a03b2b
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.ts
@@ -0,0 +1,36 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import ipaddr from 'ipaddr.js';
+
+import type {AllowedIPRange} from '@mattermost/types/config';
+
+export function isIPAddressInRanges(ipAddress: string, allowedIPRanges: AllowedIPRange[]): boolean {
+ const usersAddr = ipaddr.parse(ipAddress);
+
+ for (const allowedIPRange of allowedIPRanges) {
+ const cidrBlock = allowedIPRange.cidr_block;
+ const [addr, mask] = ipaddr.parseCIDR(cidrBlock);
+
+ if (usersAddr.kind() !== addr.kind()) {
+ // We can only compare ipv4 to ipv4 and ipv6 to ipv6, cannot compare ipv4 to ipv6
+ continue;
+ }
+
+ if (usersAddr.match([addr, mask])) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+export function validateCIDR(cidr: string) {
+ try {
+ ipaddr.parseCIDR(cidr);
+ } catch (e) {
+ return false;
+ }
+
+ return true;
+}
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.scss b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.scss
new file mode 100644
index 0000000000..33052da205
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.scss
@@ -0,0 +1,165 @@
+.SaveConfirmationModal {
+ .modal-dialog {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 600px;
+ border: 1 px solid rgba(var(--center-channel-color-rgb), 0.08);
+ margin: auto;
+ border-radius: 12px;
+ transform: translate(-50%, -50%) !important;
+ }
+
+ .modal-content {
+ border-radius: 12px;
+ }
+
+ .modal-header {
+ .close {
+ &:hover {
+ background-color:
+ rgba(
+ var(--center-channel-color-rgb),
+ 0.08
+ );
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+ }
+
+ &:active {
+ background-color: rgba(var(--button-bg-rgb), 0.08);
+ color: var(--button-bg);
+ }
+
+ top: 26px;
+ right: 26px;
+ width: 24px;
+ height: 24px;
+ border-radius: 4px;
+ color: rgba(var(--center-channel-color-rgb), 0.56) !important;
+ font-family:
+ 'Open Sans',
+ sans-serif;
+ font-size: 32px;
+ font-weight: 400;
+ }
+
+ .title {
+ font-family: Metropolis, sans-serif;
+ font-size: 22px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 28px;
+ text-align: left;
+ }
+
+ display: block;
+ min-height: 48px;
+ padding-top: 26px;
+ padding-bottom: 0;
+ padding-left: 32px;
+ border: 0;
+ background: var(--center-channel-bg) !important;
+ border-radius: 12px;
+ color: var(--center-channel-color);
+ }
+
+ .modal-body {
+ overflow: hidden;
+ width: 100%;
+ flex-direction: column;
+ padding-top: 24px;
+ padding-right: 32px;
+ padding-bottom: 26px;
+ padding-left: 32px;
+ color: var(--center-channel-color, #3f4350);
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px;
+
+ .disclaimer {
+ display: inline-flex;
+ width: 536px;
+ height: 120px;
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding: 16px;
+ border: 1px solid rgba(87, 158, 255, 0.16);
+ margin-top: 32px;
+ background: rgba(87, 158, 255, 0.08);
+ border-radius: 4px;
+ gap: 12px;
+
+ .Icon {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+
+ svg {
+ width: 20px;
+ height: 20px;
+ fill: var(--sidebar-text-active-border, rgba(93, 137, 234, 1));
+ }
+ }
+
+ .Body {
+ display: inline-flex;
+ flex: 1 1 0;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding-right: 24px;
+ gap: 8px;
+
+ .Title {
+ align-self: stretch;
+ color: #3f4350;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 20px;
+ word-wrap: break-word;
+ }
+
+ .Subtitle {
+ color: #3f4350;
+ font-family: 'Open Sans';
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 20px;
+ word-wrap: break-word;
+ }
+ }
+ }
+ }
+
+ .modal-footer {
+ padding: 24px 32px;
+ border-top: none;
+ border-radius: 12px;
+ font-family: Open Sans;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 20px;
+
+ .btn-cancel {
+ padding: 10px 20px;
+ border: none;
+ background: var(--button-bg-8, rgba(28, 88, 217, 0.08));
+ border-radius: 4px;
+ color: var(--button-bg, #1c58d9);
+ }
+
+ .btn-delete {
+ padding: 10px 20px;
+ border: none;
+ margin-left: 8px;
+ background: var(--denim-status-do-not-disturb, #d24b4e);
+ border-radius: 4px;
+ color: var(--button-color, #fff);
+ }
+ }
+}
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.test.tsx
new file mode 100644
index 0000000000..4ba1c1ae96
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.test.tsx
@@ -0,0 +1,77 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {fireEvent} from '@testing-library/react';
+import React from 'react';
+
+import {renderWithContext} from 'tests/react_testing_utils';
+
+import SaveConfirmationModal from './save_confirmation_modal';
+
+jest.mock('components/external_link', () => {
+ return jest.fn().mockImplementation(({children, ...props}) => {
+ return {children};
+ });
+});
+
+describe('SaveConfirmationModal', () => {
+ const onExitedMock = jest.fn();
+ const onConfirmMock = jest.fn();
+ const title = 'Test Title';
+ const subtitle = 'Test Subtitle';
+ const buttonText = 'Test Button Text';
+
+ const baseProps = {
+ onExited: onExitedMock,
+ onConfirm: onConfirmMock,
+ title,
+ subtitle,
+ buttonText,
+ };
+
+ test('renders the title and subtitle', () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
+
+ expect(getByText(title)).toBeInTheDocument();
+ expect(getByText(subtitle)).toBeInTheDocument();
+ });
+
+ test('renders the disclaimer if includeDisclaimer is true', () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
+
+ expect(getByText('Using the Customer Portal to restore access')).toBeInTheDocument();
+ });
+
+ test('calls onClose when the cancel button is clicked', () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
+
+ fireEvent.click(getByText('Cancel'));
+
+ expect(onExitedMock).toHaveBeenCalledTimes(1);
+ });
+
+ test('calls onConfirm when the confirm button is clicked', () => {
+ const {getByText} = renderWithContext(
+ ,
+ );
+
+ fireEvent.click(getByText(buttonText));
+
+ expect(onConfirmMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.tsx b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.tsx
new file mode 100644
index 0000000000..7f057cca7c
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.tsx
@@ -0,0 +1,85 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {Modal} from 'react-bootstrap';
+import {FormattedMessage, useIntl} from 'react-intl';
+
+import {InformationOutlineIcon} from '@mattermost/compass-icons/components';
+
+import ExternalLink from 'components/external_link';
+
+import './save_confirmation_modal.scss';
+
+type Props = {
+ onExited: () => void;
+ onConfirm?: () => void;
+ title?: string;
+ subtitle: JSX.Element | string;
+ buttonText?: string;
+ includeDisclaimer?: boolean;
+}
+
+export default function SaveConfirmationModal({onExited, onConfirm, title, subtitle, includeDisclaimer, buttonText}: Props) {
+ const {formatMessage} = useIntl();
+ return (
+
+
+
+ {title}
+
+
+
+ {subtitle}
+ {includeDisclaimer &&
+
+
+
+
+
+
{formatMessage({id: 'admin.ip_filtering.save_disclaimer_title', defaultMessage: 'Using the Customer Portal to restore access'})}
+ {/* TODO - replace "workspace owner" with owner's email address? */}
+
+ Customer Portal to disable IP filtering to restore access.'}
+ values={{
+ customerportal: (msg) => (
+
+ {msg}
+ ),
+ }}
+ />
+
+
+
+ }
+
+
+
+
+
+
+ );
+}
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 674cb0d023..ca849fed43 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
@@ -27,17 +27,19 @@ const SaveChangesPanel = ({saveNeeded, onClick, saving, serverError, cancelLink,
onClick={onClick}
savingMessage={localizeMessage('admin.team_channel_settings.saving', 'Saving Config...')}
/>
-
-
-
-
+ {
+ cancelLink !== '' &&
+
+
+
+ }
{serverError}
diff --git a/webapp/channels/src/components/common/svg_images_components/ip_filtering_earth_svg.tsx b/webapp/channels/src/components/common/svg_images_components/ip_filtering_earth_svg.tsx
new file mode 100644
index 0000000000..83e0d9fbab
--- /dev/null
+++ b/webapp/channels/src/components/common/svg_images_components/ip_filtering_earth_svg.tsx
@@ -0,0 +1,103 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+type SvgProps = {
+ width: number;
+ height: number;
+}
+
+const IPFilteringEarthSvg = ({width, height}: SvgProps) => (
+
+
+);
+
+export default IPFilteringEarthSvg;
diff --git a/webapp/channels/src/components/toggle.tsx b/webapp/channels/src/components/toggle.tsx
index 90e1858a2c..88d0e52612 100644
--- a/webapp/channels/src/components/toggle.tsx
+++ b/webapp/channels/src/components/toggle.tsx
@@ -12,7 +12,7 @@ type Props = {
offText?: React.ReactNode;
id?: string;
overrideTestId?: boolean;
- size?: 'btn-lg' | 'btn-sm';
+ size?: 'btn-lg' | 'btn-md' |'btn-sm';
toggleClassName?: string;
}
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index 58f3a6bb26..a0756bdadf 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -1179,6 +1179,45 @@
"admin.integrations.gif": "GIF (Beta)",
"admin.integrations.integrationManagement": "Integration Management",
"admin.integrations.integrationManagement.title": "Integration Management",
+ "admin.ip_filtering.add_filter": "Add a filter",
+ "admin.ip_filtering.add_ip_filter": "Add IP Filter",
+ "admin.ip_filtering.add_your_ip": "Add your IP address",
+ "admin.ip_filtering.allow_following_range": "Allow the following range of IP Addresses",
+ "admin.ip_filtering.allowed_ip_addresses": "Allowed IP Addresses",
+ "admin.ip_filtering.any_ip_can_access_add_filter": "Any IP can access your workspace. To limit access to selected IP Addresses, Add a filter.",
+ "admin.ip_filtering.apply_changes": "Yes, apply changes",
+ "admin.ip_filtering.apply_ip_filter_changes": "Apply IP Filter Changes",
+ "admin.ip_filtering.apply_ip_filter_changes_are_you_sure": "Are you sure you want to apply these IP Filter changes? Users with IP addresses outside of the IP ranges provided will no longer have access to the workspace.",
+ "admin.ip_filtering.cancel": "Cancel",
+ "admin.ip_filtering.delete": "Delete",
+ "admin.ip_filtering.delete_confirmation_body": "Are you sure you want to delete IP filter {filter}? Users with IP addresses outside of this range won't be able to access the workspace when IP Filtering is enabled",
+ "admin.ip_filtering.delete_confirmation_title": "Delete IP Filter",
+ "admin.ip_filtering.delete_filter": "Delete filter",
+ "admin.ip_filtering.disable_ip_filtering": "Disable IP Filtering",
+ "admin.ip_filtering.edit": "Edit",
+ "admin.ip_filtering.edit_ip_filter": "Edit IP Filter",
+ "admin.ip_filtering.edit_section_description_line_1": "Create rules to allow access to the workspace for specified IP addresses only.",
+ "admin.ip_filtering.edit_section_description_line_2": "NOTE: If no rules are added, all IP addresses will be allowed.",
+ "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.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",
+ "admin.ip_filtering.ip_filtering": "IP Filtering",
+ "admin.ip_filtering.more_info": "Enter ranges in CIDR format (e.g. 192.168.0.1/8). More info",
+ "admin.ip_filtering.name": "Name",
+ "admin.ip_filtering.no_filters": "No IP filtering rules added",
+ "admin.ip_filtering.no_filters_added": "Are you sure you want to apply these IP filter changes? There are currently no filters added, so all IP addresses will have access to the workspace.",
+ "admin.ip_filtering.rule_name_placeholder": "Enter a name for this rule",
+ "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.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",
+ "admin.ip_filtering.your_current_ip_is": "Your current IP address is {ip}",
+ "admin.ip_filtering.your_current_ip_is_not_in_allowed_rules": "Your IP address {ip} is not included in your allowed IP address rules.",
"admin.jobTable.cancelButton": "Cancel",
"admin.jobTable.downloadLink": "Download",
"admin.jobTable.headerExtraInfo": "Details",
@@ -2273,6 +2312,7 @@
"admin.sidebar.highAvailability": "High Availability",
"admin.sidebar.imageProxy": "Image Proxy",
"admin.sidebar.integrations": "Integrations",
+ "admin.sidebar.ip_filtering": "IP Filtering",
"admin.sidebar.ldap": "AD/LDAP",
"admin.sidebar.license": "Edition and License",
"admin.sidebar.localization": "Localization",
diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts
index 226b92873a..2c874d5373 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts
@@ -8,6 +8,7 @@ import type {
ChannelSearchOpts,
} from '@mattermost/types/channels';
import type {Compliance} from '@mattermost/types/compliance';
+import type {AllowedIPRange} from '@mattermost/types/config';
import type {
CreateDataRetentionCustomPolicy,
} from '@mattermost/types/data_retention';
@@ -845,3 +846,24 @@ export function getAppliedSchemaMigrations(): ActionFunc {
clientFunc: Client4.getAppliedSchemaMigrations,
});
}
+
+export function getIPFilters() {
+ return bindClientFunc({
+ clientFunc: Client4.getIPFilters,
+ params: [],
+ });
+}
+
+export function getCurrentIP() {
+ return bindClientFunc({
+ clientFunc: Client4.getCurrentIP,
+ params: [],
+ });
+}
+
+export function applyIPFilters(ipFilters: AllowedIPRange[]) {
+ return bindClientFunc({
+ clientFunc: Client4.applyIPFilters,
+ params: [ipFilters],
+ });
+}
diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts
index 1054aadd96..e77752f6c5 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts
@@ -152,6 +152,8 @@ const values = {
SYSCONSOLE_WRITE_SITE_PUBLIC_LINKS: 'sysconsole_write_site_public_links',
SYSCONSOLE_READ_SITE_NOTICES: 'sysconsole_read_site_notices',
SYSCONSOLE_WRITE_SITE_NOTICES: 'sysconsole_write_site_notices',
+ SYSCONSOLE_READ_SITE_IP_FILTERS: 'sysconsole_read_site_ip_filters',
+ SYSCONSOLE_WRITE_SITE_IP_FILTERS: 'sysconsole_write_site_ip_filters',
SYSCONSOLE_READ_ENVIRONMENT_WEB_SERVER: 'sysconsole_read_environment_web_server',
SYSCONSOLE_WRITE_ENVIRONMENT_WEB_SERVER: 'sysconsole_write_environment_web_server',
SYSCONSOLE_READ_ENVIRONMENT_DATABASE: 'sysconsole_read_environment_database',
diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions_sysconsole.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions_sysconsole.ts
index 01b8bae001..af24bf1ac2 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions_sysconsole.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions_sysconsole.ts
@@ -56,6 +56,7 @@ export const RESOURCE_KEYS = {
FILE_SHARING_AND_DOWNLOADS: 'site.file_sharing_and_downloads',
PUBLIC_LINKS: 'site.public_links',
NOTICES: 'site.notices',
+ IP_FILTERING: 'site.ip_filters',
},
EXPERIMENTAL: {
FEATURES: 'experimental.features',
@@ -101,6 +102,7 @@ export const ResourceToSysConsolePermissionsTable: Record = {
[RESOURCE_KEYS.SITE.FILE_SHARING_AND_DOWNLOADS]: [Permissions.SYSCONSOLE_READ_SITE_FILE_SHARING_AND_DOWNLOADS, Permissions.SYSCONSOLE_WRITE_SITE_FILE_SHARING_AND_DOWNLOADS],
[RESOURCE_KEYS.SITE.PUBLIC_LINKS]: [Permissions.SYSCONSOLE_READ_SITE_PUBLIC_LINKS, Permissions.SYSCONSOLE_WRITE_SITE_PUBLIC_LINKS],
[RESOURCE_KEYS.SITE.NOTICES]: [Permissions.SYSCONSOLE_READ_SITE_NOTICES, Permissions.SYSCONSOLE_WRITE_SITE_NOTICES],
+ [RESOURCE_KEYS.SITE.IP_FILTERING]: [Permissions.SYSCONSOLE_READ_SITE_IP_FILTERS, Permissions.SYSCONSOLE_WRITE_SITE_IP_FILTERS],
[RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER]: [Permissions.SYSCONSOLE_READ_ENVIRONMENT_WEB_SERVER, Permissions.SYSCONSOLE_WRITE_ENVIRONMENT_WEB_SERVER],
[RESOURCE_KEYS.ENVIRONMENT.DATABASE]: [Permissions.SYSCONSOLE_READ_ENVIRONMENT_DATABASE, Permissions.SYSCONSOLE_WRITE_ENVIRONMENT_DATABASE],
[RESOURCE_KEYS.ENVIRONMENT.ELASTICSEARCH]: [Permissions.SYSCONSOLE_READ_ENVIRONMENT_ELASTICSEARCH, Permissions.SYSCONSOLE_WRITE_ENVIRONMENT_ELASTICSEARCH],
diff --git a/webapp/channels/src/sass/components/_toggle.scss b/webapp/channels/src/sass/components/_toggle.scss
index 68acedb814..e75f74aadb 100644
--- a/webapp/channels/src/sass/components/_toggle.scss
+++ b/webapp/channels/src/sass/components/_toggle.scss
@@ -84,6 +84,10 @@ $toggle-default-font-size: 0.75rem;
@include toggle-mixin($size: 2.8rem, $font-size: 1rem, $margin: 5rem);
}
+ &.btn-md {
+ @include toggle-mixin($size: 2.2rem, $font-size: 0.8rem, $margin: 3rem);
+ }
+
&.btn-sm {
@include toggle-mixin($font-size: 0.55rem, $margin: 0.5rem);
}
@@ -123,6 +127,10 @@ $toggle-default-font-size: 0.75rem;
@include toggle-mixin($size: 2.8rem, $font-size: 1rem, $margin: 5rem);
}
+ &.btn-md {
+ @include toggle-mixin($size: 2.2rem, $font-size: 0.8rem, $margin: 3rem);
+ }
+
&.btn-sm {
@include toggle-mixin($font-size: 0.55rem, $margin: 0.5rem);
}
diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx
index 3586fabbcc..bce023ac39 100644
--- a/webapp/channels/src/utils/constants.tsx
+++ b/webapp/channels/src/utils/constants.tsx
@@ -446,6 +446,9 @@ export const ModalIdentifiers = {
START_TRIAL_FORM_MODAL: 'start_trial_form_modal',
START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result',
CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel',
+ IP_FILTERING_ADD_EDIT_MODAL: 'ip_filtering_add_edit_modal',
+ IP_FILTERING_DELETE_CONFIRMATION_MODAL: 'ip_filtering_delete_confirmation_modal',
+ IP_FILTERING_SAVE_CONFIRMATION_MODAL: 'ip_filtering_save_confirmation_modal',
};
export const UserStatuses = {
diff --git a/webapp/package-lock.json b/webapp/package-lock.json
index f22c25350f..c3b318484a 100644
--- a/webapp/package-lock.json
+++ b/webapp/package-lock.json
@@ -92,6 +92,7 @@
"hoist-non-react-statics": "3.3.2",
"html-to-react": "1.6.0",
"inobounce": "0.2.1",
+ "ipaddr.js": "2.1.0",
"katex": "0.16.3",
"key-mirror": "1.0.1",
"localforage": "1.10.0",
@@ -17770,7 +17771,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
"integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==",
- "dev": true,
"engines": {
"node": ">= 10"
}
@@ -39659,8 +39659,7 @@
"ipaddr.js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
- "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==",
- "dev": true
+ "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ=="
},
"is-alphabetical": {
"version": "1.0.4",
@@ -41857,6 +41856,7 @@
"identity-obj-proxy": "3.0.0",
"image-webpack-loader": "8.1.0",
"inobounce": "0.2.1",
+ "ipaddr.js": "2.1.0",
"isomorphic-fetch": "3.0.0",
"jest": "29.7.0",
"jest-canvas-mock": "2.5.0",
diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts
index 0e40561b1d..2ff355009a 100644
--- a/webapp/platform/client/src/client4.ts
+++ b/webapp/platform/client/src/client4.ts
@@ -61,6 +61,9 @@ import {
AdminConfig,
EnvironmentConfig,
RequestLicenseBody,
+ AllowedIPRanges,
+ AllowedIPRange,
+ FetchIPResponse,
} from '@mattermost/types/config';
import {CustomEmoji} from '@mattermost/types/emojis';
import {ServerError} from '@mattermost/types/errors';
@@ -4161,6 +4164,27 @@ export default class Client4 {
);
};
+ getIPFilters = () => {
+ return this.doFetch(
+ `${this.getBaseRoute()}/ip_filtering`,
+ {method: 'get'},
+ )
+ }
+
+ getCurrentIP = () => {
+ return this.doFetch(
+ `${this.getBaseRoute()}/ip_filtering/my_ip`,
+ {method: 'get'},
+ )
+ }
+
+ applyIPFilters = (filters: AllowedIPRanges) => {
+ return this.doFetch(
+ `${this.getBaseRoute()}/ip_filtering`,
+ {method: 'post', body: JSON.stringify(filters)},
+ )
+ }
+
submitTrueUpReview = () => {
return this.doFetch(
`${this.getBaseRoute()}/license/review`,
diff --git a/webapp/platform/types/src/admin.ts b/webapp/platform/types/src/admin.ts
index caf19c5566..c93ecbc233 100644
--- a/webapp/platform/types/src/admin.ts
+++ b/webapp/platform/types/src/admin.ts
@@ -3,7 +3,7 @@
import {Audit} from './audits';
import {Compliance} from './compliance';
-import {AdminConfig, ClientLicense, EnvironmentConfig} from './config';
+import {AdminConfig, AllowedIPRange, ClientLicense, EnvironmentConfig} from './config';
import {DataRetentionCustomPolicies} from './data_retention';
import {MixedUnlinkedGroupRedux} from './groups';
import {PluginRedux, PluginStatusRedux} from './plugins';
diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts
index 8157b88c14..1924e5c5fc 100644
--- a/webapp/platform/types/src/config.ts
+++ b/webapp/platform/types/src/config.ts
@@ -977,3 +977,17 @@ export enum ServiceEnvironment {
TEST = 'test',
DEV = 'dev',
}
+
+
+export type AllowedIPRange = {
+ cidr_block: string;
+ description: string;
+ enabled: boolean;
+ owner_id: string;
+}
+
+export type AllowedIPRanges = AllowedIPRange[];
+
+export type FetchIPResponse = {
+ ip: string;
+}