* Initial comit for ip filtering service implementation

* Add audit logs for IP Filters

* start of webapp work

* Stashing

* Updates based on Agniva's feedback around service vs einterface

* Updates completed

* Commit before refactoring, everything's working

* First pass of cleanup complete, front-end tests added

* actually add files

* Updates to some translation strings, running i18n-extract

* Lock everything behind a feature flag

* Fix tests, try to fix some linter stuff

* Fixed linter for JS, on to scss

* Fixed linter for scss

* Fix linter

* More fixes for pipeline

* Support for IPV6

* Remove tsx file that was removed in masteR

* Revert package.json and package-lock.json to master, add cidr-regex dep into channels/package.json

* Another commit to force fix Github

* Fixes around IPV6. Some suggestions from Matt re: UX review. Fixing pipelines for tests and types on new cidr-regex package

* Changes to address Matt's feedback

* A few more changes for clean up

* Add support for permissions

* Fix vet for OpenAPI spec

* Actually add the yaml file for openapi

* Add permission migration to allow support for IP Filtering

* Fix tests

* Final fixes from Matt

* Remove cancel button from page, update link outs to documentation

* Update test to account for removed cancel button

* Adjustments based on feedback from Harrison

* More fixes from PR feedback

* Add a t to fix translations that doesn't seem to be breaking anyone else?

* More fix

* updates for PR feedback

* Fix linter

* Fix types

* Now fix the linter again

* Add back tests because Harrison was able to get them running

* Adjustments for PR feedback

* Remove admin_definition.jsx

* Fix linter

* [CLD-6453] IP Filtering notification email for sysadmins (#25224)

* Initial commit for IP filtering alert email

* Updates to style for email, addition of ip_filtering email:

* Fix pipelines

* Adjustments from Matt's feedback

* Padding changes

* template diff (#25249)

Co-authored-by: Gabe Jackson <3694686+gabrieljackson@users.noreply.github.com>

* Fix hardcoded true, remove bool return value

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Gabe Jackson <3694686+gabrieljackson@users.noreply.github.com>

* Lock feature behind enterprise license. Drop cidr-regex in favour of ipaddr.js dependency. Refactor isIpAddressWithinRanges to use ipaddr.js

* Add a couple server tests

* fix linter

* Fix types from merge conflicts

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Gabe Jackson <3694686+gabrieljackson@users.noreply.github.com>
Этот коммит содержится в:
Nick Misasi
2023-11-14 09:12:04 -05:00
коммит произвёл GitHub
родитель 7bf9be2619
Коммит e1c851a3ca
82 изменённых файлов: 4533 добавлений и 19 удалений

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

@@ -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

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

@@ -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'

92
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"

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

@@ -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))

139
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)
}

310
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)
})
}

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

@@ -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
}

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

@@ -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)

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

@@ -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
}

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

@@ -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)

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

@@ -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()
}

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

@@ -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)

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

@@ -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")

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

@@ -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()

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

@@ -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 {

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

@@ -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)

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

@@ -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)
}

11
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)
}

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

@@ -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)

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

@@ -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
}

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

@@ -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"

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

@@ -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 {

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

@@ -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
}

20
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"`
}

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

@@ -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"
)

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

@@ -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{

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

@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -306,6 +306,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

580
server/templates/ip_filters_changed.html Обычный файл
Просмотреть файл

@@ -0,0 +1,580 @@
{{define "ip_filters_changed"}}
<!-- FILE: ip_filters_changed.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,600,700);
.emailBody {
background-color: #F3F3F3
}
.emailBody a {
text-decoration: none !important;
color: #1C58D9;
}
.title div {
font-weight: 600 !important;
font-size: 28px !important;
line-height: 36px !important;
letter-spacing: -0.01em !important;
color: #3F4350 !important;
font-family: Open Sans, sans-serif !important;
}
.subTitle div {
font-size: 16px !important;
line-height: 24px !important;
color: rgba(63, 67, 80, 0.64) !important;
}
.subTitle a {
color: rgb(28, 88, 217) !important;
}
.button a {
background-color: #1C58D9 !important;
font-weight: 600 !important;
font-size: 16px !important;
line-height: 18px !important;
color: #FFFFFF !important;
padding: 15px 24px !important;
}
.button-cloud a {
background-color: #1C58D9 !important;
font-weight: 400 !important;
font-size: 16px !important;
line-height: 18px !important;
color: #FFFFFF !important;
padding: 15px 24px !important;
}
.messageButton a {
background-color: #FFFFFF !important;
border: 1px solid #FFFFFF !important;
box-sizing: border-box !important;
color: #1C58D9 !important;
padding: 12px 20px !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 14px !important;
}
.info div {
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 40px 0px !important;
}
.footerTitle div {
font-weight: 600 !important;
font-size: 16px !important;
line-height: 24px !important;
color: #3F4350 !important;
padding: 0px 0px 4px 0px !important;
}
.footerInfo div {
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 0px 48px 0px 48px !important;
}
.footerInfo a {
color: #1C58D9 !important;
}
.appDownloadButton a {
background-color: #FFFFFF !important;
border: 1px solid #1C58D9 !important;
box-sizing: border-box !important;
color: #1C58D9 !important;
padding: 13px 20px !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 14px !important;
}
.emailFooter div {
font-size: 12px !important;
line-height: 16px !important;
color: rgba(63, 67, 80, 0.56) !important;
padding: 8px 24px 8px 24px !important;
}
.postCard {
padding: 0px 24px 40px 24px !important;
}
.messageCard {
background: #FFFFFF !important;
border: 1px solid rgba(61, 60, 64, 0.08) !important;
box-sizing: border-box !important;
box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.12) !important;
border-radius: 4px !important;
padding: 32px !important;
}
.messageAvatar img {
width: 32px !important;
height: 32px !important;
padding: 0px !important;
border-radius: 32px !important;
}
.messageAvatarCol {
width: 32px !important;
}
.postNameAndTime {
padding: 0px 0px 4px 0px !important;
display: flex;
}
.senderName {
font-family: Open Sans, sans-serif;
text-align: left !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
}
.time {
font-family: Open Sans, sans-serif;
font-size: 12px;
line-height: 16px;
color: rgba(63, 67, 80, 0.56);
padding: 2px 6px;
align-items: center;
float: left;
}
.channelBg {
background: rgba(63, 67, 80, 0.08);
border-radius: 4px;
display: flex;
padding-left: 4px;
}
.channelLogo {
width: 10px;
height: 10px;
padding: 5px 4px 5px 6px;
float: left;
}
.channelName {
font-family: Open Sans, sans-serif;
font-weight: 600;
font-size: 10px;
line-height: 16px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: rgba(63, 67, 80, 0.64);
padding: 2px 6px 2px 0px;
}
.gmChannelCount {
background-color: rgba(63, 67, 80, 0.2);
padding: 0 5px;
border-radius: 2px;
margin-right: 2px;
}
.senderMessage div {
text-align: left !important;
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 0px !important;
}
.senderInfoCol {
width: 394px !important;
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
}
}
@media all and (max-width: 540px) and (min-width: 401px) {
.emailBody {
padding: 16px !important;
}
.messageCard {
padding: 16px !important;
}
.senderInfoCol {
width: 80% !important;
padding: 0px 0px 0px 12px !important;
}
}
@media all and (max-width: 400px) {
.emailBody {
padding: 0px !important;
}
.footerInfo div {
padding: 0px !important;
}
.messageCard {
padding: 16px !important;
}
.postCard {
padding: 0px 0px 40px 0px !important;
}
.senderInfoCol {
width: 80% !important;
padding: 0px 0px 0px 12px !important;
}
}
@media only screen and (min-width:480px) {
.mj-column-per-50 {
width: 100% !important;
max-width: 100% !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#FFFFFF;">
<div class="emailBody" style="background-color: #FFFFFF;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;border-radius:8px;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;border-radius:8px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 0px 40px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:132px;">
<img alt height="21" src="{{.Props.SiteURL}}/static/images/logo_email_dark.png" style="border:0;display:block;outline:none;text-decoration:none;height:21.76px;width:100%;font-size:13px;" width="132">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 24px 40px 24px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:504px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="title" style="font-size:0px;padding:0px;word-break:break-word;">
<div style="text-align: center; font-weight: 600; font-size: 28px; line-height: 36px; letter-spacing: -0.01em; color: #3F4350; font-family: Open Sans, sans-serif;">{{.Props.Title}}</div>
</td>
</tr>
<tr>
<td align="center" class="subTitle" style="font-size:0px;padding:16px 24px 16px 24px;word-break:break-word;">
<div style="font-family: Open Sans, sans-serif; text-align: center; font-size: 16px; line-height: 24px; color: rgba(63, 67, 80, 0.64);">{{.Props.SubTitle}}</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" class="button" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#FFFFFF" role="presentation" style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:10px 25px;background:#FFFFFF;" valign="middle">
<a href="{{.Props.ButtonURL}}" style="display: inline-block; background: #FFFFFF; font-family: Open Sans, sans-serif; margin: 0; text-transform: none; mso-padding-alt: 0px; border-radius: 4px; text-decoration: none; background-color: #1C58D9; font-weight: 600; font-size: 16px; line-height: 18px; color: #FFFFFF; padding: 15px 24px;" target="_blank">
{{.Props.Button}}
</a>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:312px;">
<img alt height="auto" src="{{.Props.SiteURL}}/static/images/forgot_password_illustration.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="312">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:40px 0px 40px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="footerTitle" style="font-size:0px;padding:0px;padding-bottom:9px;word-break:break-word;">
<div style="font-family: Open Sans, sans-serif; text-align: center; font-weight: 600; font-size: 16px; line-height: 24px; color: #3F4350; padding: 0px 0px 4px 0px;">{{.Props.TroubleAccessingTitle}}</div>
</td>
</tr>
{{if .Props.ActorEmail}}
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:0px;padding-top:0px;padding-bottom:1px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="transparent" role="presentation" style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:10px 25px;background:transparent;" valign="middle">
<a href="mailto:{{.Props.ActorEmail}}" style="display: inline-block; background: transparent; color: #1C58D9; font-family: Open Sans, sans-serif; font-size: 14px; font-weight: normal; line-height: 20px; margin: 0; text-transform: none; padding: 10px 25px; mso-padding-alt: 0px; border-radius: 4px; text-decoration: none;" target="_blank">
{{.Props.SendAnEmailTo}}
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" class="divider" style="opacity: 12%; font-size: 0px; padding: 0; word-break: break-word;">
<p style="border-top:solid 1px #3F4350;font-size:1px;margin:0px auto;width:313px;">
</p>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #3F4350;font-size:1px;margin:0px auto;width:313px;" role="presentation" width="313px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]-->
</td>
</tr>
{{end}}{{ if .Props.LogInToCustomerPortal}}
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:0px;padding-top:6px;padding-bottom:1px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="transparent" role="presentation" style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:10px 25px;background:transparent;" valign="middle">
<a href="{{.Props.PortalURL}}/console/cloud/ip-filtering" style="display: inline-block; background: transparent; color: #1C58D9; font-family: Open Sans, sans-serif; font-size: 14px; font-weight: normal; line-height: 20px; margin: 0; text-transform: none; padding: 10px 25px; mso-padding-alt: 0px; border-radius: 4px; text-decoration: none;" target="_blank">
{{.Props.LogInToCustomerPortal}}
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" class="divider" style="opacity: 12%; font-size: 0px; padding: 0px; word-break: break-word;">
<p style="border-top:solid 1px #3F4350;font-size:1px;margin:0px auto;width:313px;">
</p>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #3F4350;font-size:1px;margin:0px auto;width:313px;" role="presentation" width="313px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]-->
</td>
</tr>
{{end}}
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:0px;padding-top:6px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="transparent" role="presentation" style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:10px 25px;background:transparent;" valign="middle">
<a href="mailto:{{.Props.SupportEmail}}" style="display: inline-block; background: transparent; color: #1C58D9; font-family: Open Sans, sans-serif; font-size: 14px; font-weight: normal; line-height: 20px; margin: 0; text-transform: none; padding: 10px 25px; mso-padding-alt: 0px; border-radius: 4px; text-decoration: none;" target="_blank">
{{.Props.ContactSupport}}
</a>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="emailFooter" style="font-size:0px;padding:0px;word-break:break-word;">
<div style="font-family: Open Sans, sans-serif; text-align: center; font-size: 12px; line-height: 16px; color: rgba(63, 67, 80, 0.56); padding: 8px 24px 8px 24px;">{{.Props.Organization}}
{{.Props.FooterV2}}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
{{end}}

40
server/templates/ip_filters_changed.mjml Обычный файл
Просмотреть файл

@@ -0,0 +1,40 @@
<mjml>
<mj-head>
<mj-include path="./partials/style.mjml" />
</mj-head>
<mj-body css-class="emailBody" background-color="#FFFFFF">
<mj-wrapper mj-class="email">
<mj-include path="./partials/logo.mjml" />
<mj-include path="./partials/header.mjml" />
<mj-section padding="0px">
<mj-column>
<mj-image src="{{.Props.SiteURL}}/static/images/forgot_password_illustration.png" width="312px"
padding="0px" />
</mj-column>
</mj-section>
<mj-section padding="40px 0px 40px 0px">
<mj-column>
<mj-text padding-bottom="9px" css-class="footerTitle" padding="0px">
{{.Props.TroubleAccessingTitle}}
</mj-text>
<mj-raw>{{if .Props.ActorEmail}}</mj-raw>
<mj-button padding-top="0px" padding-bottom="1px" font-size="14px" line-height="20px" background-color="transparent" color="#1C58D9" href="mailto:{{.Props.ActorEmail}}">
{{.Props.SendAnEmailTo}}
</mj-button>
<mj-divider padding="0" css-class="divider" width="313px" border-width="1px" border-color="#3F4350"/>
<mj-raw>{{end}}</mj-raw>
<mj-raw>{{ if .Props.LogInToCustomerPortal}}</mj-raw>
<mj-button padding-top="6px" padding-bottom="1px" font-size="14px" line-height="20px" background-color="transparent" color="#1C58D9" href="{{.Props.PortalURL}}/console/cloud/ip-filtering">
{{.Props.LogInToCustomerPortal}}
</mj-button>
<mj-divider padding="0px" css-class="divider" width="313px" border-width="1px" border-color="#3F4350"/>
<mj-raw>{{end}}</mj-raw>
<mj-button padding-top="6px" font-size="14px" line-height="20px" background-color="transparent" color="#1C58D9" href="mailto:{{.Props.SupportEmail}}">
{{.Props.ContactSupport}}
</mj-button>
</mj-column>
</mj-section>
<mj-include path="./partials/email_footer.mjml" />
</mj-wrapper>
</mj-body>
</mjml>

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

@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -306,6 +306,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -193,6 +193,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -296,6 +296,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -286,6 +286,10 @@
padding: 0px 0px 0px 12px !important;
}
.divider {
opacity: 12%;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;

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

@@ -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: ['<rootDir>/src/tests/setup_jest.ts'],

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

@@ -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",

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

@@ -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,

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

@@ -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: {

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

@@ -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);
}
}
}

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

@@ -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 <a {...props}>{children}</a>;
});
});
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(
<IPFilteringAddOrEditModal
{...baseProps}
/>,
);
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(
<IPFilteringAddOrEditModal
{...baseProps}
existingRange={undefined}
/>,
);
expect(getByText('Add IP Filter')).toBeInTheDocument();
});
test('renders the modal with the correct inputs and values', () => {
const {getByLabelText} = renderWithContext(
<IPFilteringAddOrEditModal
{...baseProps}
/>,
);
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(
<IPFilteringAddOrEditModal
{...baseProps}
/>,
);
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(
<IPFilteringAddOrEditModal
{...baseProps}
existingRange={undefined}
/>,
);
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(
<IPFilteringAddOrEditModal
{...baseProps}
/>,
);
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(
<IPFilteringAddOrEditModal
{...baseProps}
/>,
);
fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: 'invalid-cidr'}});
fireEvent.blur(getByLabelText('Enter IP Range'));
expect(getByTestId('save-add-edit-button')).toBeDisabled();
});
});

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

@@ -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<CustomMessageInputType>(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<HTMLInputElement>) => {
const cidr = e.target.value;
setCIDR(cidr);
setCIDRError(null);
};
const validateCIDRInput = () => {
if (!validateCIDR(CIDR)) {
setCIDRError({type: 'error', value: 'Invalid CIDR address range'});
}
};
return (
<Modal
className={'IPFilteringAddOrEditModal'}
dialogClassName={'IPFilteringAddOrEditModal__dialog'}
show={true}
onExited={onExited}
onHide={onExited}
>
<Modal.Header closeButton={true}>
<div className='title'>
{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'})}
</div>
</Modal.Header>
<Modal.Body>
<div className='body'>
<div className='current_ip_notice'>
<div className='Content'>
<span><InformationOutlineIcon/>{formatMessage({id: 'admin.ip_filtering.your_current_ip_is', defaultMessage: 'Your current IP address is {ip}'}, {ip: currentIP})}</span>
</div>
</div>
<div className='inputs'>
<div>
{formatMessage({id: 'admin.ip_filtering.name', defaultMessage: 'Name'})}
<Input
type='text'
name='name'
onChange={(e) => 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}
/>
</div>
<div>{formatMessage({id: 'admin.ip_filtering.allow_following_range', defaultMessage: 'Allow the following range of IP Addresses'})}
<Input
type='text'
name='ip_address_range'
onChange={handleCIDRChange}
onBlur={validateCIDRInput}
value={CIDR}
placeholder={'Enter IP Range'}
required={true}
useLegend={false}
customMessage={CIDRError}
/>
</div>
<p>
<FormattedMessage
id={'admin.ip_filtering.more_info'}
defaultMessage={'Enter ranges in CIDR format (e.g. 192.168.0.1/8). <link>More info</link>'}
values={{
link: (msg) => (
<ExternalLink
href='https://docs.mattermost.com/guides/cloud-workspace-management.html'
location={'ip_filtering_add_edit_rule_modal'}
>
{msg}
</ExternalLink>
),
}}
/>
</p>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<button
type='button'
className='btn-cancel'
onClick={onExited}
>
{formatMessage({id: 'admin.ip_filtering.cancel', defaultMessage: 'Cancel'})}
</button>
<button
data-testid='save-add-edit-button'
type='button'
className='btn-save'
onClick={handleSave}
disabled={Boolean(CIDRError) || !CIDR.length || !name.length}
>
{existingRange ? formatMessage({id: 'admin.ip_filtering.update_filter', defaultMessage: 'Update filter'}) : formatMessage({id: 'admin.ip_filtering.save', defaultMessage: 'Save'})}
</button>
</Modal.Footer>
</Modal>
);
}

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

@@ -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);
}
}
}

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

@@ -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(
<DeleteConfirmationModal
{...baseProps}
/>,
);
expect(getByText('Delete IP Filter')).toBeInTheDocument();
});
test('renders the modal with the correct filter name in description', () => {
const {getByText} = render(
<DeleteConfirmationModal
{...baseProps}
/>,
);
expect(getByText('Test IP Filter')).toBeInTheDocument();
});
test('calls the onClose function when the Cancel button is clicked', () => {
const {getByText} = render(
<DeleteConfirmationModal
{...baseProps}
/>,
);
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(
<DeleteConfirmationModal
{...baseProps}
/>,
);
fireEvent.click(getByText('Delete filter'));
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith(filterToDelete);
});
});
});

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

@@ -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 (
<Modal
className={'DeleteConfirmationModal'}
dialogClassName={'DeleteConfirmationModal__dialog'}
show={true}
onExited={onExited}
onHide={onExited}
>
<Modal.Header closeButton={true}>
<div className='title'>
{formatMessage({id: 'admin.ip_filtering.delete_confirmation_title', defaultMessage: 'Delete IP Filter'})}
</div>
</Modal.Header>
<Modal.Body>
{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: (<strong>{filterToDelete?.description}</strong>)},
)}
</Modal.Body>
<Modal.Footer>
<button
type='button'
className='btn-cancel'
onClick={onExited}
>
{formatMessage({id: 'admin.ip_filtering.cancel', defaultMessage: 'Cancel'})}
</button>
<button
type='button'
className='btn-delete'
onClick={() => onConfirm?.(filterToDelete!)}
>
{formatMessage({id: 'admin.ip_filtering.delete_filter', defaultMessage: 'Delete filter'})}
</button>
</Modal.Footer>
</Modal>
);
}

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

@@ -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(
<EditSection
{...baseProps}
/>,
);
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(
<EditSection
{...baseProps}
/>,
);
fireEvent.click(screen.getByText('Add Filter'));
expect(setShowAddModal).toHaveBeenCalledTimes(1);
expect(setShowAddModal).toHaveBeenCalledWith(true);
});
test('clicking the Edit button calls setEditFilter', () => {
renderWithContext(
<EditSection
{...baseProps}
/>,
);
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(
<EditSection
{...baseProps}
/>,
);
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(
<EditSection
{...baseProps}
currentUsersIP='192.168.1.1'
currentIPIsInRange={false}
/>,
);
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(
<EditSection
{...baseProps}
ipFilters={[]}
/>,
);
expect(screen.getByText('No IP filtering rules added')).toBeInTheDocument();
expect(screen.getByText('Add a filter')).toBeInTheDocument();
});
});

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

@@ -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 = <Tooltip id='edit-tooltip'>{formatMessage({id: 'admin.ip_filtering.edit', defaultMessage: 'Edit'})}</Tooltip>;
const deleteTooltip = <Tooltip id='delete-tooltip'>{formatMessage({id: 'admin.ip_filtering.delete', defaultMessage: 'Delete'})}</Tooltip>;
return (
<div
className='Row'
onMouseEnter={() => handleRowMouseEnter(index)}
onMouseLeave={handleRowMouseLeave}
>
<div className='FilterName'>{allowedIPRange.description}</div>
<div className='IpAddressRange'>{allowedIPRange.cidr_block}</div>
<div className='Actions'>
{hoveredRow === index && (
<>
<OverlayTrigger
placement='top'
overlay={editTooltip}
>
<div
className='edit'
aria-label='Edit'
role='button'
onClick={() => setEditFilter(allowedIPRange)}
>
<PencilOutlineIcon size={20}/>
</div>
</OverlayTrigger>
<OverlayTrigger
placement='top'
overlay={deleteTooltip}
>
<div
className='delete'
aria-label='Delete'
role='button'
onClick={() => handleConfirmDeleteFilter(allowedIPRange)}
>
<TrashCanOutlineIcon
size={20}
color='red'
/>
</div>
</OverlayTrigger>
</>
)}
</div>
</div>
);
};
export default EditTableRow;

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

@@ -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) => (
<div className='AllowedIPAddressesSection'>
<div className='SectionHeaderContent'>
<div className='HeaderContent'>
<div className='TitleSubtitle'>
<div className='Title'>
<FormattedMessage
id='admin.ip_filtering.allowed_ip_addresses'
defaultMessage='Allowed IP Addresses'
/>
</div>
<div className='Subtitle'>
<FormattedMessage
id='admin.ip_filtering.edit_section_description_line_1'
defaultMessage='Create rules to allow access to the workspace for specified IP addresses only.'
/>
</div>
<div className='Subtitle'>
<FormattedMessage
id='admin.ip_filtering.edit_section_description_line_2'
defaultMessage='<strong>NOTE:</strong> If no rules are added, all IP addresses will be allowed.'
values={{
strong: (msg) => <strong>{msg}</strong>,
}}
/>
</div>
</div>
<div className='AddIPFilterButton'>
<button
className='Button'
onClick={() => {
setShowAddModal(true);
}}
type='button'
>
<FormattedMessage
id='admin.ip_filtering.add_filter'
defaultMessage='Add Filter'
/>
</button>
</div>
</div>
{
!currentIPIsInRange &&
<IPNotInRangeErrorPanel
setShowAddModal={setShowAddModal}
currentUsersIP={currentUsersIP}
/>
}
</div>
</div>
);
export default EditSectionHeader;

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

@@ -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) => (
<div className='NotInRangeErrorPanel'>
<div className='Icon'>
<AlertOutlineIcon size={20}/>
</div>
<div className='Content'>
<div className='Title'>
<FormattedMessage
id='admin.ip_filtering.your_current_ip_is_not_in_allowed_rules'
defaultMessage='Your IP address {ip} is not included in your allowed IP address rules.'
values={{ip: currentUsersIP}}
/>
</div>
<div className='Body'>
<FormattedMessage
id='admin.ip_filtering.include_your_ip'
defaultMessage='Include your IP address in at least one of the rules below to continue.'
/>
<div
className='Button'
onClick={() => setShowAddModal(true)}
>
<FormattedMessage
id='admin.ip_filtering.add_your_ip'
defaultMessage='Add your IP address'
/>
</div>
</div>
</div>
</div>
);
export default IPNotInRangeErrorPanel;

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

@@ -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) => (
<div className='NoFilters'>
<div>
<IPFilteringEarthSvg
width={149}
height={140}
/>
</div>
<div className='Title'>
<FormattedMessage
id='admin.ip_filtering.no_filters'
defaultMessage='No IP filtering rules added'
/>
</div>
<div className='Subtitle'>
<FormattedMessage
id='admin.ip_filtering.any_ip_can_access_add_filter'
defaultMessage='Any IP can access your workspace. To limit access to selected IP Addresses, <add>Add a filter</add>'
values={{
add: (msg) => (
<div
onClick={() => setShowAddModal(true)}
className='Button'
>
{msg}
</div>
),
}}
/>
</div>
</div>
);
export default NoFiltersPanel;

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

@@ -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;
}
}
}
}
}
}
}

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

@@ -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<number | null>(null);
return (
<div className='EditSection'>
<EditSectionHeader
setShowAddModal={setShowAddModal}
currentIPIsInRange={currentIPIsInRange}
currentUsersIP={currentUsersIP}
/>
{Boolean(ipFilters?.length) && (
<div className='TableSectionContent'>
<div className='Table'>
<div className='HeaderRow'>
<div className='FilterName'>
{formatMessage({
id: 'admin.ip_filtering.filter_name',
defaultMessage: 'Filter Name',
})}
</div>
<div className='IpAddressRange'>
{formatMessage({
id: 'admin.ip_filtering.ip_address_range',
defaultMessage: 'IP Address Range',
})}
</div>
</div>
{ipFilters?.map((allowedIPRange, index) => (
<EditTableRow
key={allowedIPRange.cidr_block}
allowedIPRange={allowedIPRange}
index={index}
handleRowMouseEnter={(index) => setHoveredRow(index)}
handleRowMouseLeave={() => setHoveredRow(null)}
setEditFilter={setEditFilter}
handleConfirmDeleteFilter={handleConfirmDeleteFilter}
hoveredRow={hoveredRow}
/>
))}
</div>
</div>
)}
{ipFilters?.length === 0 && <NoFiltersPanel setShowAddModal={setShowAddModal}/>}
</div>
);
};
export default EditSection;

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

@@ -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 <a {...props}>{children}</a>;
});
});
describe('EnableSectionContent', () => {
const filterToggle = true;
const setFilterToggle = jest.fn();
const baseProps = {
filterToggle,
setFilterToggle,
};
test('renders the component', () => {
renderWithContext(
<EnableSectionContent
{...baseProps}
/>,
);
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(
<EnableSectionContent
{...baseProps}
/>,
);
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(
<EnableSectionContent
{...baseProps}
filterToggle={false}
/>,
);
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();
});
});

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

@@ -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<Props> = ({filterToggle, setFilterToggle}) => {
const {formatMessage} = useIntl();
return (
<div className='EnableSectionContent'>
<div className='TitleSubtitleContent'>
<div className='TitleSubtitle'>
<div className='Title'>
{formatMessage({id: 'admin.ip_filtering.enable_ip_filtering', defaultMessage: 'Enable IP Filtering'})}
</div>
<div className='Subtitle'>
<FormattedMessage
id={'admin.ip_filtering.enable_ip_filtering_description'}
defaultMessage={'Limit access to your workspace by IP address. <learnmore>Learn more in the docs</learnmore>'}
values={{
learnmore: (msg) => (
<ExternalLink
href='https://docs.mattermost.com/guides/cloud-workspace-management.html'
location={'ip_filtering_enable_section'}
>
{msg}
</ExternalLink>
),
}}
/>
</div>
</div>
<div className='SwitchSelector'>
<Toggle
size={'btn-md'}
id={'filterToggle'}
disabled={false}
onToggle={() => setFilterToggle(!filterToggle)}
toggled={filterToggle}
toggleClassName='btn-toggle-primary'
/>
</div>
</div>
</div>
);
};
export default EnableSectionContent;

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

@@ -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<AllowedIPRange[] | null>(null);
const [originalIpFilters, setOriginalIpFilters] = useState<AllowedIPRange[] | null>(null);
const [saveNeeded, setSaveNeeded] = useState(false);
const [currentUsersIP, setCurrentUsersIP] = useState<string | null>(null);
const [saving, setSaving] = useState<boolean>(false);
const [filterToggle, setFilterToggle] = useState<boolean>(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 = (
<FormattedMessage
id={'admin.ip_filtering.no_filters_added'}
defaultMessage={'Are you sure you want to apply these IP filter changes? There are currently no filters added, so <strong>all IP addresses will have access to the workspace.</strong>'}
values={{
strong: (content: string) => <strong>{content}</strong>,
}}
/>
);
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 = (
<FormattedMessage
id={'admin.ip_filtering.turn_off_ip_filtering'}
defaultMessage={'Are you sure you want to turn off IP Filtering? <strong>All IP addresses will have access to the workspace.</strong>'}
values={{
strong: (content: string) => <strong>{content}</strong>,
}}
/>
);
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 = (
<FormattedMessage
id={'admin.ip_filtering.apply_ip_filter_changes_are_you_sure'}
defaultMessage={'Are you sure you want to apply these IP Filter changes? <strong>Users with IP addresses outside of the IP ranges provided will no longer have access to the workspace.</strong>'}
values={{
strong: (content: string) => <strong>{content}</strong>,
}}
/>
);
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 (
<>
<AlertOutlineIcon size={16}/> {formatMessage({id: 'admin.ip_filtering.error_on_page', defaultMessage: 'Your IP address is not included in your filters'})}
</>
);
};
return (
<div className='IPFiltering wrapper--fixed'>
<AdminHeader>
{formatMessage({id: 'admin.ip_filtering.ip_filtering', defaultMessage: 'IP Filtering'})}
</AdminHeader>
<div className='MainPanel admin-console__wrapper'>
<>
<EnableSectionContent
filterToggle={filterToggle}
setFilterToggle={setFilterToggle}
/>
{ipFilters !== null && currentUsersIP !== null && filterToggle &&
<EditSection
ipFilters={ipFilters}
currentUsersIP={currentUsersIP}
setShowAddModal={showAddModal}
setEditFilter={showEditModal}
handleConfirmDeleteFilter={showConfirmDeleteFilterModal}
currentIPIsInRange={currentIPIsInRange()}
/>
}
</>
</div>
<SaveChangesPanel
saving={saving}
saveNeeded={saveNeeded}
isDisabled={!currentIPIsInRange}
onClick={handleSaveClick}
serverError={saveBarError()}
cancelLink=''
/>
</div>
);
};
export default IPFiltering;

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

@@ -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;
}
}
}

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

@@ -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) => (
<Router>
<IntlProvider {...intlProviderProps}>
<Provider store={mockedStore} >
<ModalController/>
{component}
</Provider>
</IntlProvider>
</Router>
);
test('renders the IP Filtering page', async () => {
const {getByText} = render(wrapWithIntlProviderAndStore(<IPFiltering/>));
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(<IPFiltering/>));
await waitFor(() => {
expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument();
expect(screen.getByRole('button', {pressed: true})).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('filterToggle-button'));
await waitFor(() => {
expect(screen.getByRole('button', {pressed: false})).toBeInTheDocument();
});
});
test('adds a new IP filter when the "Add IP Filter" button is clicked', async () => {
const {getByLabelText, getByText} = render(wrapWithIntlProviderAndStore(<IPFiltering/>));
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(<IPFiltering/>));
await waitFor(() => {
expect(getByText('Test IP Filter')).toBeInTheDocument();
});
fireEvent.mouseEnter(screen.getByText('Test IP Filter'));
fireEvent.click(screen.getByRole('button', {
name: /Edit/i,
}));
const descriptionInput = getByLabelText('Enter a name for this rule');
const cidrInput = getByLabelText('Enter IP Range');
const saveButton = screen.getByTestId('save-add-edit-button');
fireEvent.change(cidrInput, {target: {value: '192.168.0.0/16'}});
fireEvent.change(descriptionInput, {target: {value: 'zzzzzfilter'}});
fireEvent.click(saveButton);
await waitFor(() => {
expect(getByText('zzzzzfilter')).toBeInTheDocument();
expect(getByText('192.168.0.0/16')).toBeInTheDocument();
// ensure that the old description is gone, because we've now changed it
expect(queryByText('Test IP Filter')).toBeNull();
});
});
test('deletes an existing IP filter when the "Delete" button is clicked', async () => {
const {getByText, queryByText} = render(wrapWithIntlProviderAndStore(<IPFiltering/>));
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(<IPFiltering/>));
await waitFor(() => {
expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument();
expect(screen.getByRole('button', {pressed: true})).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('filterToggle-button'));
await waitFor(() => {
expect(screen.getByRole('button', {pressed: false})).toBeInTheDocument();
});
await waitFor(() => {
expect(queryByText('Test IP Filter')).not.toBeInTheDocument();
});
fireEvent.click(getByText('Save'));
fireEvent.click(screen.getByTestId('save-confirmation-button'));
await waitFor(() => {
expect(applyIPFiltersMock).toHaveBeenCalledTimes(1);
});
});
});

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

@@ -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();
}
});
});

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

@@ -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;
}

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

@@ -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);
}
}
}

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

@@ -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 <a {...props}>{children}</a>;
});
});
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(
<SaveConfirmationModal
{...baseProps}
/>,
);
expect(getByText(title)).toBeInTheDocument();
expect(getByText(subtitle)).toBeInTheDocument();
});
test('renders the disclaimer if includeDisclaimer is true', () => {
const {getByText} = renderWithContext(
<SaveConfirmationModal
{...baseProps}
includeDisclaimer={true}
/>,
);
expect(getByText('Using the Customer Portal to restore access')).toBeInTheDocument();
});
test('calls onClose when the cancel button is clicked', () => {
const {getByText} = renderWithContext(
<SaveConfirmationModal
{...baseProps}
/>,
);
fireEvent.click(getByText('Cancel'));
expect(onExitedMock).toHaveBeenCalledTimes(1);
});
test('calls onConfirm when the confirm button is clicked', () => {
const {getByText} = renderWithContext(
<SaveConfirmationModal
{...baseProps}
/>,
);
fireEvent.click(getByText(buttonText));
expect(onConfirmMock).toHaveBeenCalledTimes(1);
});
});

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

@@ -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 (
<Modal
className={'SaveConfirmationModal'}
dialogClassName={'SaveConfirmationModal__dialog'}
show={true}
onExited={onExited}
onHide={onExited}
>
<Modal.Header closeButton={true}>
<div className='title'>
{title}
</div>
</Modal.Header>
<Modal.Body>
{subtitle}
{includeDisclaimer &&
<div className='disclaimer'>
<div className='Icon'>
<InformationOutlineIcon/>
</div>
<div className='Body'>
<div className='Title'>{formatMessage({id: 'admin.ip_filtering.save_disclaimer_title', defaultMessage: 'Using the Customer Portal to restore access'})}</div>
{/* TODO - replace "workspace owner" with owner's email address? */}
<div className='Subtitle'>
<FormattedMessage
id={'admin.ip_filtering.save_disclaimer_subtitle'}
defaultMessage={'If you happen to block yourself with these settings, your workspace owner can log in to the <customerportal>Customer Portal</customerportal> to disable IP filtering to restore access.'}
values={{
customerportal: (msg) => (
<ExternalLink
href='https://customers.mattermost.com/console/ip_filtering'
>
{msg}
</ExternalLink>),
}}
/>
</div>
</div>
</div>
}
</Modal.Body>
<Modal.Footer>
<button
type='button'
className='btn-cancel'
onClick={onExited}
>
{formatMessage({id: 'admin.ip_filtering.cancel', defaultMessage: 'Cancel'})}
</button>
<button
data-testid='save-confirmation-button'
type='button'
className='btn-delete'
onClick={() => onConfirm?.()}
>
{buttonText}
</button>
</Modal.Footer>
</Modal>
);
}

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

@@ -27,17 +27,19 @@ const SaveChangesPanel = ({saveNeeded, onClick, saving, serverError, cancelLink,
onClick={onClick}
savingMessage={localizeMessage('admin.team_channel_settings.saving', 'Saving Config...')}
/>
<BlockableLink
id='cancelButtonSettings'
className='btn btn-quaternary'
to={cancelLink}
>
<FormattedMessage
id='admin.team_channel_settings.cancel'
defaultMessage='Cancel'
/>
</BlockableLink>
{
cancelLink !== '' &&
<BlockableLink
id='cancelButtonSettings'
className='btn btn-quaternary'
to={cancelLink}
>
<FormattedMessage
id='admin.team_channel_settings.cancel'
defaultMessage='Cancel'
/>
</BlockableLink>
}
<div className='error-message'>
{serverError}
</div>

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

@@ -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) => (
<svg
width={width}
height={height}
viewBox='0 0 140 140'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<ellipse
cx='69.5'
cy='120'
rx='29.5'
ry='3'
fill='black'
fillOpacity='0.06'
/>
<path
d='M113.191 70.0004C113.19 78.1542 110.882 86.1411 106.533 93.0378C102.184 99.9345 95.9719 105.459 88.6152 108.974C81.2586 112.488 73.0577 113.848 64.9607 112.897C56.8636 111.945 49.2012 108.721 42.8592 103.598C36.5172 98.4739 31.7546 91.6597 29.1222 83.9427C26.4897 76.2257 26.0948 67.9212 27.9832 59.9892C29.8715 52.0572 33.9661 44.8217 39.7933 39.1192C45.6205 33.4168 52.9425 29.4802 60.9128 27.7647H62.1351L63.0116 27.3418C63.7189 27.2342 64.4339 27.1342 65.1489 27.0496L66.6865 27.0958L67.7397 26.8421H68.3548L70.0538 27.0804L71.1763 26.8267H71.8221L74.19 27.4264L76.9884 27.3495L77.9802 27.5187L88.2206 34.0467L99.3991 38.2987C103.764 42.3429 107.242 47.2479 109.616 52.7043C111.99 58.1607 113.208 64.0499 113.191 70.0004Z'
fill='#1C58D9'
/>
<path
d='M72.0989 40.4363C74.9127 41.3359 74.7974 39.5598 76.5734 41.6281C77.5959 42.8199 79.441 44.9882 81.6321 45.5264C81.9396 45.6033 82.6238 44.9421 82.6238 44.4192C82.6238 43.8964 81.0863 42.7892 80.2406 42.5585C79.3949 42.3278 80.5865 40.6978 79.4256 40.198C78.2647 39.6982 76.9808 37.8451 76.5195 36.3919C76.0583 34.9387 74.0133 36.1612 73.3136 35.8691C72.614 35.5769 71.776 35.7537 71.0072 36.8609C70.2384 37.9682 71.3762 40.2057 72.0989 40.4363Z'
fill='#FFBC1F'
/>
<path
d='M87.3369 81.1265C87.3369 80.5191 81.7708 77.4896 81.0942 77.4512C79.9871 77.382 78.4803 76.8284 77.4654 77.4512C77.0368 77.6945 76.6729 78.0374 76.4045 78.4508C76.2123 78.8122 75.1283 78.1432 74.7132 78.3662C73.7137 78.8967 73.1755 77.1667 73.4292 76.5055C74.1288 74.8831 71.661 75.9057 71.4073 75.4597C71.7071 75.9903 71.3227 70.8463 70.4616 73.7451C69.8082 75.9442 67.2481 73.4298 67.5556 71.8305C68.0322 69.3624 71.7532 69.001 72.5682 69.4393C72.9833 69.6622 74.4979 71.8536 74.6901 71.7921C75.1052 71.6614 74.4441 69.2086 74.3595 68.8856C74.0673 67.6862 76.3584 67.2863 76.5198 66.033C76.6582 64.9719 77.3809 65.341 78.0574 64.7797C79.0953 63.957 78.2804 62.4192 79.595 61.6349C80.0486 61.3658 81.7477 61.1736 81.9707 60.6968C82.1936 60.2201 80.5407 59.0745 81.9707 58.7361C83.2315 58.4286 84.5154 58.9745 84.8613 57.1522C85.1688 55.4991 83.3237 55.1223 82.7932 53.8382C82.6241 53.4538 82.4166 51.2394 81.6324 51.6469C80.6714 52.1543 80.0948 53.3769 79.2799 51.9237C77.8576 49.3017 74.3364 48.1714 75.9893 52.2927C76.835 54.4226 73.6829 60.9044 73.1986 54.9147C73.0371 52.831 66.8098 52.2082 68.901 49.9015C70.9921 47.5948 73.9213 47.4871 74.7055 43.8964C75.7972 38.9139 72.3452 43.1275 70.5846 42.1894C67.9553 40.7516 70.1849 41.5281 68.8241 43.3889C68.309 44.0963 66.6176 42.9429 65.8642 43.0583C65.1108 43.1736 63.3809 44.7037 62.8966 43.7426C62.0202 41.9818 55.8467 40.8976 54.1784 41.5205C50.6957 42.8276 49.4887 41.0591 46.0137 40.3748C44.8067 40.1364 38.8792 39.9673 39.2636 42.0202C39.5404 42.7727 39.864 43.5071 40.2323 44.2193C40.609 45.3496 38.9099 44.4807 38.5179 44.7498C39.21 45.3637 39.9337 45.9412 40.6859 46.4799C41.2548 47.0642 38.5102 47.7716 38.2334 48.7404C37.6875 50.7088 42.0082 50.1475 41.2164 52.5157C40.9703 53.2308 38.5025 54.4072 38.2641 56.8293C38.218 57.2906 41.3393 53.8306 43.3536 54.5226C45.1295 55.1685 44.9758 50.3398 47.1976 50.3398C51.3184 50.3398 52.118 54.3534 54.286 57.0446C55.3008 58.2979 56.1158 58.8207 56.3003 60.4585C56.4848 62.0963 56.1388 63.8416 56.6924 65.2949C57.2459 66.7481 58.2915 66.7789 58.9988 67.9014C59.8445 69.2163 60.3211 70.5003 61.4666 71.646C63.1272 73.3145 65.9564 75.8673 68.5704 75.4905C69.0855 75.4136 71.0306 76.5208 71.4688 76.8514C72.0759 77.4221 72.5941 78.0804 73.0064 78.8045C73.4677 79.4196 74.9284 79.0351 75.5665 79.3273C76.2046 79.6195 74.0827 83.9407 74.8515 85.0479C75.6203 86.1551 75.6895 87.7006 76.9427 88.6848C78.1958 89.669 78.4803 89.6921 78.6417 91.3145C78.8801 93.9441 78.1112 96.6045 77.4808 99.0573C77.1502 100.311 77.8114 101.702 77.3194 102.902C76.7043 104.401 76.2892 105.693 76.8274 107.361C77.7576 110.26 82.9778 110.729 79.9026 108.2C78.5879 107.123 81.3249 104.524 80.4715 104.278C79.0031 103.663 80.7405 103.109 80.9328 102.471C81.3248 101.203 87.1447 95.951 87.5752 95.0821C87.9366 94.3516 87.7059 93.5981 88.2979 92.9676C88.8899 92.3371 89.8355 92.514 90.6043 92.0219C92.1419 90.9915 91.5884 89.2461 92.2265 87.8237C93.4873 85.0018 94.0178 86.1167 92.288 83.91C91.6345 83.1103 87.4522 83.4025 87.3369 81.1265Z'
fill='#FFBC1F'
/>
<path
d='M56.2 38.4295C57.261 39.5367 58.3142 37.8528 58.5064 38.4295C58.6986 39.0062 58.714 40.1134 59.9748 40.198C61.2357 40.2826 58.7986 41.1053 59.6135 41.4513C60.4284 41.7973 62.1736 42.9045 62.8117 42.4432C63.4498 41.9818 64.2109 41.6282 64.4954 41.9742C64.7798 42.3202 66.5327 41.9203 66.033 41.336C65.5333 40.7516 64.2032 39.3753 64.2571 38.5833C64.3109 37.7913 65.7947 35.7537 64.1418 35.9306C63.743 35.9807 63.3796 36.185 63.1296 36.4998C62.8796 36.8145 62.7627 37.2146 62.804 37.6145C62.804 38.1989 61.2664 36.7456 60.9435 36.7687C60.6206 36.7918 59.5443 35.0002 59.1368 35.0002C58.7294 35.0002 56.0001 34.1237 56.2307 35.0002C56.4614 35.8768 55.1391 37.3223 56.2 38.4295Z'
fill='#FFBC1F'
/>
<path
d='M80.6022 46.749C79.7796 46.28 78.2189 44.1347 77.527 44.8268C76.8351 45.5188 77.7038 47.1335 78.2958 47.7947C78.8878 48.456 81.3248 50.0092 80.9712 49.3325C80.6175 48.6559 82.2167 47.6794 80.6022 46.749Z'
fill='#FFBC1F'
/>
<path
d='M57.8068 31.9554C58.2297 31.9169 58.8909 33.3855 59.5136 33.547C60.1363 33.7085 60.4439 34.4389 60.6745 34.6081C60.9051 34.7772 63.5114 33.7777 64.1264 33.547C64.7415 33.3163 63.3576 31.8785 62.8425 30.8712C62.3274 29.864 62.7272 32.5397 61.9968 32.1015C61.2665 31.6632 61.3741 31.0634 60.4592 31.0634C59.5443 31.0634 60.1824 29.2412 59.06 29.6256C57.9375 30.0101 57.384 31.9938 57.8068 31.9554Z'
fill='#FFBC1F'
/>
<path
d='M67.1554 35.4462C66.3866 35.5999 66.5326 36.0997 66.1021 36.5303C65.9011 36.7358 65.7886 37.0118 65.7886 37.2992C65.7886 37.5866 65.9011 37.8626 66.1021 38.0681C66.4942 38.5679 67.1092 39.4598 67.5782 38.6063C68.0472 37.7529 67.5782 37.0685 67.5782 36.5149C67.5782 35.9613 67.5321 35.3693 67.1554 35.4462Z'
fill='#FFBC1F'
/>
<path
d='M68.7011 38.1604C68.8933 38.6218 69.2469 37.3454 70.0003 36.6841C70.7538 36.0229 69.6313 34.3774 68.9317 35.2155C68.4627 35.7922 68.5089 37.6914 68.7011 38.1604Z'
fill='#FFBC1F'
/>
<path
d='M68.5626 38.737C68.1628 39.306 67.8399 40.8284 68.5626 40.9514C68.7209 40.9697 68.8811 40.9437 69.0256 40.8763C69.17 40.8089 69.2928 40.7028 69.3805 40.5697C69.4681 40.4366 69.5172 40.2818 69.5221 40.1225C69.527 39.9632 69.4876 39.8056 69.4083 39.6674C69.07 39.0907 68.7317 38.5063 68.5626 38.737Z'
fill='#FFBC1F'
/>
<path
d='M71.4916 28.9256C70.7996 29.2409 72.5064 29.8176 71.3378 30.1328C70.1692 30.4481 70.1385 31.4092 69.1313 30.5941C68.1242 29.7791 67.0248 30.8709 67.7859 31.363C68.547 31.8551 69.8232 33.07 69.7848 33.7313C69.7464 34.3925 70.9918 34.7001 72.1527 34.6232C73.3136 34.5463 74.7129 34.8923 74.7513 34.3925C74.7897 33.8927 74.9281 33.6775 74.7897 33.3007C74.5898 32.7548 74.0901 32.2165 72.8908 32.6856C71.6914 33.1546 70.9072 32.9162 70.7381 32.3319C70.569 31.7475 73.0907 30.6249 74.2054 31.4015C75.3202 32.1781 76.3812 30.9786 75.5662 30.4327C74.7513 29.8868 75.2971 29.3101 75.8584 29.2332C76.2658 29.1794 76.6271 28.149 76.9347 27.3494C75.2246 27.0728 73.4993 26.9007 71.7683 26.8342C71.776 27.9338 72.1835 28.6104 71.4916 28.9256Z'
fill='#FFBC1F'
/>
<path
d='M61.1819 27.957C61.9507 28.2877 63.227 27.957 62.9886 27.365C62.289 27.488 61.5894 27.6187 60.8975 27.7648C60.9744 27.852 61.0723 27.9182 61.1819 27.957Z'
fill='#FFBC1F'
/>
<path
d='M61.6585 28.6107C60.5668 28.0494 60.6283 29.4257 61.128 29.9716C61.6277 30.5175 63.0731 29.3027 61.6585 28.6107Z'
fill='#FFBC1F'
/>
<path
d='M68.3013 26.8345C68.6367 26.895 68.9503 27.0427 69.2108 27.2627C69.4712 27.4826 69.6692 27.7672 69.7851 28.0878C69.9311 28.5799 71.1074 27.5111 71.1228 26.8114H70.0003C69.4314 26.8037 68.8702 26.8114 68.3013 26.8345Z'
fill='#FFBC1F'
/>
<path
d='M65.9942 28.4492C65.5022 29.1258 65.925 29.9408 66.5093 29.2642C67.0936 28.5876 66.6246 26.8729 67.0859 27.6111C67.5472 28.3492 68.4083 29.5179 68.9849 29.5718C69.5615 29.6256 69.0618 28.8874 68.9849 28.2723C68.908 27.6572 67.5319 27.0728 67.6856 26.8652C66.8246 26.9114 65.9712 26.9806 65.1255 27.0728C65.7328 27.4727 66.371 27.9648 65.9942 28.4492Z'
fill='#FFBC1F'
/>
<path
d='M68.5625 34.2775C69.2467 34.5543 68.5625 32.8858 68.5625 32.8858C67.8475 33.3087 67.8859 33.993 68.5625 34.2775Z'
fill='#FFBC1F'
/>
<path
d='M66.1024 33.224C66.2177 33.693 66.9558 33.8468 67.3478 33.224C67.7399 32.6012 67.3478 30.3406 66.7943 31.0557C66.2407 31.7708 65.6026 31.4401 64.4879 31.0557C63.3731 30.6712 65.9948 32.7319 66.1024 33.224Z'
fill='#FFBC1F'
/>
<path
d='M73.6826 72.8378C74.6359 72.7225 75.2202 73.8835 76.2966 73.8297C77.3729 73.7759 79.0027 75.4059 79.933 74.4371C80.7018 73.6682 76.6118 73.0147 75.3202 72.315C74.0286 71.6153 72.737 72.9531 73.6826 72.8378Z'
fill='#FFBC1F'
/>
<path
d='M78.4337 29.1181C79.7714 29.7639 79.5408 29.7409 78.7259 30.3637C77.9109 30.9865 78.2031 32.3398 80.0021 32.2475C81.8011 32.1552 82.9159 30.3637 83.7308 32.8857C84.5457 35.4077 83.9614 38.1603 85.007 38.9369C86.0526 39.7135 85.1838 40.2364 85.007 40.9591C84.8302 41.6819 85.2991 42.2047 86.1141 41.9741C86.929 41.7434 86.6984 43.2581 86.1141 43.8963C85.5298 44.5345 84.8379 45.7571 86.6369 47.9715C88.4359 50.1859 88.6742 49.8861 89.2508 50.6473C89.8274 51.4085 90.3041 50.3628 90.4732 49.4862C90.6424 48.6097 90.0657 45.5264 92.1338 45.5264C92.778 45.5195 93.4029 45.3057 93.9164 44.9167C94.4299 44.5277 94.8049 43.984 94.9861 43.3658C95.2167 42.8429 96.8466 42.8968 97.7153 42.1432C98.5841 41.3897 99.3529 40.9053 99.3759 38.2987C93.4007 32.7528 85.9725 29.0196 77.9571 27.5341C77.7649 28.1339 77.7726 28.8105 78.4337 29.1181Z'
fill='#FFBC1F'
/>
</svg>
);
export default IPFilteringEarthSvg;

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

@@ -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;
}

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

@@ -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>Add a filter</add>.",
"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? <strong>Users with IP addresses outside of the IP ranges provided will no longer have access to the workspace.</strong>",
"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": "<strong>NOTE:</strong> 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. <learnmore>Learn more in the docs</learnmore>",
"admin.ip_filtering.error_on_page": "Your IP address is not included in your filters",
"admin.ip_filtering.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). <link>More info</link>",
"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 <strong>all IP addresses will have access to the workspace.</strong>",
"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 <customerportal>Customer Portal</customerportal> to disable IP filtering to restore access.",
"admin.ip_filtering.save_disclaimer_title": "Using the Customer Portal to restore access",
"admin.ip_filtering.turn_off_ip_filtering": "Are you sure you want to turn off IP Filtering? <strong>All IP addresses will have access to the workspace.</strong>",
"admin.ip_filtering.update_filter": "Update filter",
"admin.ip_filtering.yes_disable_ip_filtering": "Yes, disable IP Filtering",
"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",

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

@@ -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],
});
}

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

@@ -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',

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

@@ -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<string, string[]> = {
[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],

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

@@ -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);
}

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

@@ -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 = {

6
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",

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

@@ -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<AllowedIPRange[]>(
`${this.getBaseRoute()}/ip_filtering`,
{method: 'get'},
)
}
getCurrentIP = () => {
return this.doFetch<FetchIPResponse>(
`${this.getBaseRoute()}/ip_filtering/my_ip`,
{method: 'get'},
)
}
applyIPFilters = (filters: AllowedIPRanges) => {
return this.doFetch<AllowedIPRange[]>(
`${this.getBaseRoute()}/ip_filtering`,
{method: 'post', body: JSON.stringify(filters)},
)
}
submitTrueUpReview = () => {
return this.doFetch(
`${this.getBaseRoute()}/license/review`,

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

@@ -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';

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

@@ -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;
}