From e1c851a3ca70f234ca15f137dc3382ec4b983bf5 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Tue, 14 Nov 2023 09:12:04 -0500 Subject: [PATCH] [CLD-6324] Cloud IP Filtering (#24726) * 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 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 Co-authored-by: Gabe Jackson <3694686+gabrieljackson@users.noreply.github.com> --- api/Makefile | 1 + api/v4/source/definitions.yaml | 9 + api/v4/source/ip_filters.yaml | 92 +++ server/channels/api4/api.go | 5 + server/channels/api4/ip_filtering.go | 139 +++++ server/channels/api4/ip_filtering_test.go | 310 ++++++++++ server/channels/app/app.go | 5 + server/channels/app/app_iface.go | 1 + server/channels/app/email/email.go | 37 ++ .../app/email/mocks/ServiceInterface.go | 14 + server/channels/app/email/service.go | 1 + server/channels/app/enterprise.go | 6 + .../app/opentracing/opentracing_layer.go | 17 + server/channels/app/permissions_migrations.go | 25 + server/channels/app/server.go | 7 +- server/channels/testlib/store.go | 1 + server/einterfaces/cloud.go | 3 + server/einterfaces/ip_filtering.go | 11 + server/einterfaces/mocks/CloudInterface.go | 52 ++ .../einterfaces/mocks/IPFilteringInterface.go | 82 +++ server/i18n/en.json | 48 ++ server/public/model/client4.go | 50 ++ server/public/model/feature_flags.go | 4 +- server/public/model/ip_filtering.go | 20 + server/public/model/migration.go | 1 + server/public/model/permission.go | 19 + server/templates/cloud_14_day_arrears.html | 4 + server/templates/cloud_30_day_arrears.html | 4 + server/templates/cloud_45_day_arrears.html | 4 + server/templates/cloud_7_day_arrears.html | 4 + server/templates/cloud_90_day_arrears.html | 4 + .../templates/cloud_upgrade_confirmation.html | 4 + server/templates/invite_body.html | 4 + server/templates/ip_filters_changed.html | 580 ++++++++++++++++++ server/templates/ip_filters_changed.mjml | 40 ++ server/templates/license_up_for_renewal.html | 4 + server/templates/messages_notification.html | 4 + server/templates/partials/style.css | 4 + server/templates/payment_failed_body.html | 4 + server/templates/reset_body.html | 4 + server/templates/verify_body.html | 4 + server/templates/welcome_body.html | 4 + webapp/channels/jest.config.js | 2 +- webapp/channels/package.json | 1 + webapp/channels/src/actions/admin_actions.jsx | 27 + .../admin_console/admin_definition.tsx | 15 + .../add_edit_ip_filter_modal.scss | 202 ++++++ .../add_edit_ip_filter_modal.test.tsx | 144 +++++ .../ip_filtering/add_edit_ip_filter_modal.tsx | 146 +++++ .../ip_filtering/delete_confirmation.scss | 109 ++++ .../ip_filtering/delete_confirmation.test.tsx | 70 +++ .../ip_filtering/delete_confirmation.tsx | 59 ++ .../edit_section/edit_section.test.tsx | 122 ++++ .../edit_section_edit_table_row.tsx | 85 +++ .../edit_section/edit_section_header.tsx | 72 +++ .../edit_section_ip_not_in_range_panel.tsx | 49 ++ .../edit_section_no_filters_panel.tsx | 46 ++ .../edit_section/edit_sections.scss | 298 +++++++++ .../ip_filtering/edit_section/index.tsx | 78 +++ .../ip_filtering/enable_section.test.tsx | 65 ++ .../ip_filtering/enable_section.tsx | 57 ++ .../admin_console/ip_filtering/index.tsx | 269 ++++++++ .../ip_filtering/ip_filtering.scss | 82 +++ .../ip_filtering/ip_filtering.test.tsx | 204 ++++++ .../ip_filtering/ip_filtering_utils.test.ts | 99 +++ .../ip_filtering/ip_filtering_utils.ts | 36 ++ .../ip_filtering/save_confirmation_modal.scss | 165 +++++ .../save_confirmation_modal.test.tsx | 77 +++ .../ip_filtering/save_confirmation_modal.tsx | 85 +++ .../save_changes_panel.tsx | 24 +- .../ip_filtering_earth_svg.tsx | 103 ++++ webapp/channels/src/components/toggle.tsx | 2 +- webapp/channels/src/i18n/en.json | 40 ++ .../mattermost-redux/src/actions/admin.ts | 22 + .../src/constants/permissions.ts | 2 + .../src/constants/permissions_sysconsole.ts | 2 + .../channels/src/sass/components/_toggle.scss | 8 + webapp/channels/src/utils/constants.tsx | 3 + webapp/package-lock.json | 6 +- webapp/platform/client/src/client4.ts | 24 + webapp/platform/types/src/admin.ts | 2 +- webapp/platform/types/src/config.ts | 14 + 82 files changed, 4533 insertions(+), 19 deletions(-) create mode 100644 api/v4/source/ip_filters.yaml create mode 100644 server/channels/api4/ip_filtering.go create mode 100644 server/channels/api4/ip_filtering_test.go create mode 100644 server/einterfaces/ip_filtering.go create mode 100644 server/einterfaces/mocks/IPFilteringInterface.go create mode 100644 server/public/model/ip_filtering.go create mode 100644 server/templates/ip_filters_changed.html create mode 100644 server/templates/ip_filters_changed.mjml create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.scss create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.test.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.scss create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.test.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section.test.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_edit_table_row.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_header.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_ip_not_in_range_panel.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_sections.scss create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/edit_section/index.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/enable_section.test.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/enable_section.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/index.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.test.ts create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.ts create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.scss create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.test.tsx create mode 100644 webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.tsx create mode 100644 webapp/channels/src/components/common/svg_images_components/ip_filtering_earth_svg.tsx diff --git a/api/Makefile b/api/Makefile index 387da3e31a..6660b91935 100644 --- a/api/Makefile +++ b/api/Makefile @@ -49,6 +49,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/permissions.yaml >> $(V4_YAML) @cat $(V4_SRC)/imports.yaml >> $(V4_YAML) @cat $(V4_SRC)/exports.yaml >> $(V4_YAML) + @cat $(V4_SRC)/ip_filters.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi @echo Extracting code samples diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index e36868ba63..8b44bc6eb7 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3515,6 +3515,15 @@ components: description: The time in milliseconds in which this acknowledgement was made. type: integer format: int64 + AllowedIPRange: + type: object + properties: + CIDRBlock: + description: An IP address range in CIDR notation + type: string + Description: + description: A description for the CIDRBlock + type: string externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/api/v4/source/ip_filters.yaml b/api/v4/source/ip_filters.yaml new file mode 100644 index 0000000000..4e87bfcc7c --- /dev/null +++ b/api/v4/source/ip_filters.yaml @@ -0,0 +1,92 @@ + /api/v4/ip_filtering: + get: + tags: + - ip + - filtering + summary: Get all IP filters + description: > + Retrieve a list of IP filters applied to the workspace + + __Minimum server version__: 9.1 + __Note:__ This is intended for internal use and only applicable to Cloud workspaces + operationId: GetIPFilters + responses: + "200": + description: IP Filters returned successfully + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AllowedIPRange" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" + post: + tags: + - ip + - filtering + summary: Get all IP filters + description: > + Adjust IP Filters applied to the workspace + + __Minimum server version__: 9.1 + __Note:__ This is intended for internal use and only applicable to Cloud workspaces + operationId: ApplyIPFilters + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AllowedIPRange" + description: IP Filters to apply + required: true + responses: + "200": + description: IP Filters returned successfully + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AllowedIPRange" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" + /api/v4/ip_filtering/my_ip: + get: + tags: + - ip + - filtering + summary: Get all IP filters + description: > + Retrieve your current IP address as seen by the workspace + + __Minimum server version__: 9.1 + __Note:__ This is intended for internal use and only applicable to Cloud workspaces + operationId: MyIP + responses: + "200": + description: IP address returned successfully + content: + application/json: + schema: + type: object + properties: + ip: + type: string + description: Your current IP address + example: "192.168.0.1" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 5275df3b4b..c6a6957981 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -137,6 +137,8 @@ type Routes struct { HostedCustomer *mux.Router // 'api/v4/hosted_customer' Drafts *mux.Router // 'api/v4/drafts' + + IPFiltering *mux.Router // 'api/v4/ip_filtering' } type API struct { @@ -261,6 +263,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter() + api.BaseRoutes.IPFiltering = api.BaseRoutes.APIRoot.PathPrefix("/ip_filtering").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -304,6 +308,7 @@ func Init(srv *app.Server) (*API, error) { api.InitUsage() api.InitHostedCustomer() api.InitDrafts() + api.InitIPFiltering() srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/server/channels/api4/ip_filtering.go b/server/channels/api4/ip_filtering.go new file mode 100644 index 0000000000..4ab48a5907 --- /dev/null +++ b/server/channels/api4/ip_filtering.go @@ -0,0 +1,139 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/app" + "github.com/mattermost/mattermost/server/v8/channels/audit" + "github.com/mattermost/mattermost/server/v8/einterfaces" +) + +func (api *API) InitIPFiltering() { + api.BaseRoutes.IPFiltering.Handle("", api.APISessionRequired(getIPFilters)).Methods("GET") + api.BaseRoutes.IPFiltering.Handle("", api.APISessionRequired(applyIPFilters)).Methods("POST") + api.BaseRoutes.IPFiltering.Handle("/my_ip", api.APISessionRequired(myIP)).Methods("GET") +} + +func ensureIPFilteringInterface(c *Context, where string) (einterfaces.IPFilteringInterface, bool) { + if c.App.IPFiltering() == nil || !c.App.Config().FeatureFlags.CloudIPFiltering || c.App.License() == nil || c.App.License().SkuShortName != model.LicenseShortSkuEnterprise { + c.Err = model.NewAppError(where, "api.context.ip_filtering.not_available.app_error", nil, "", http.StatusNotImplemented) + return nil, false + } + return c.App.IPFiltering(), true +} + +func getIPFilters(c *Context, w http.ResponseWriter, r *http.Request) { + ipFiltering, ok := ensureIPFilteringInterface(c, "getIPFilters") + if !ok { + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadIPFilters) { + c.SetPermissionError(model.PermissionSysconsoleReadIPFilters) + return + } + + allowedRanges, err := ipFiltering.GetIPFilters() + if err != nil { + c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + if err := json.NewEncoder(w).Encode(allowedRanges); err != nil { + c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } +} + +func applyIPFilters(c *Context, w http.ResponseWriter, r *http.Request) { + ipFiltering, ok := ensureIPFilteringInterface(c, "applyIPFilters") + if !ok { + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteIPFilters) { + c.SetPermissionError(model.PermissionSysconsoleWriteIPFilters) + return + } + + auditRec := c.MakeAuditRecord("applyIPFilters", audit.Fail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + + allowedRanges := &model.AllowedIPRanges{} // Initialize the allowedRanges variable + if err := json.NewDecoder(r.Body).Decode(allowedRanges); err != nil { + c.Err = model.NewAppError("applyIPFilters", "api.context.ip_filtering.apply_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + audit.AddEventParameterAuditable(auditRec, "IPFilter", allowedRanges) + + updatedAllowedRanges, err := ipFiltering.ApplyIPFilters(allowedRanges) + + if err != nil { + c.Err = model.NewAppError("applyIPFilters", "api.context.ip_filtering.apply_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + auditRec.Success() + + c.App.Srv().Go(func() { + initiatingUser, err := c.App.Srv().Store().User().GetProfileByIds(context.Background(), []string{c.AppContext.Session().UserId}, nil, true) + if err != nil { + mlog.Error("Failed to get initiating user", mlog.Err(err)) + } + + users, err := c.App.Srv().Store().User().GetSystemAdminProfiles() + if err != nil { + mlog.Error("Failed to get system admins", mlog.Err(err)) + } + + cloudWorkspaceOwnerEmailAddress := "" + if c.App.License().IsCloud() { + portalUserCustomer, cErr := c.App.Cloud().GetCloudCustomer(c.AppContext.Session().UserId) + if cErr != nil { + mlog.Error("Failed to get portal user customer", mlog.Err(cErr)) + } + if cErr == nil && portalUserCustomer != nil { + cloudWorkspaceOwnerEmailAddress = portalUserCustomer.Email + } + } + + for _, user := range users { + if err = c.App.Srv().EmailService.SendIPFiltersChangedEmail(user.Email, initiatingUser[0], *c.App.Config().ServiceSettings.SiteURL, *c.App.Config().CloudSettings.CWSURL, user.Locale, cloudWorkspaceOwnerEmailAddress == user.Email); err != nil { + mlog.Error("Error while sending IP filters changed email", mlog.Err(err)) + } + } + }) + + if err := json.NewEncoder(w).Encode(updatedAllowedRanges); err != nil { + c.Err = model.NewAppError("getIPFilters", "api.context.ip_filtering.get_ip_filters.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } +} + +func myIP(c *Context, w http.ResponseWriter, r *http.Request) { + _, ok := ensureIPFilteringInterface(c, "myIP") + + if !ok { + return + } + + response := &model.GetIPAddressResponse{ + IP: c.AppContext.IPAddress(), + } + + json, err := json.Marshal(response) + if err != nil { + c.Err = model.NewAppError("myIP", "api.context.ip_filtering.get_my_ip.failed", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(json) +} diff --git a/server/channels/api4/ip_filtering_test.go b/server/channels/api4/ip_filtering_test.go new file mode 100644 index 0000000000..cedf79cb67 --- /dev/null +++ b/server/channels/api4/ip_filtering_test.go @@ -0,0 +1,310 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +package api4 + +import ( + "context" + "os" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" + "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" + "github.com/stretchr/testify/require" +) + +func Test_getIPFilters(t *testing.T) { + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + Cloud: model.NewBool(true), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuEnterprise, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + + t.Run("No license returns 501", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + ipFiltering := &mocks.IPFilteringInterface{} + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + + th.App.Srv().RemoveLicense() + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + ipFilters, r, err := th.Client.GetIPFilters(context.Background()) + require.Error(t, err) + require.Nil(t, ipFilters) + require.Equal(t, 501, r.StatusCode) + }) + + t.Run("No feature flag returns 501", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + ipFiltering := &mocks.IPFilteringInterface{} + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + + th.App.Srv().SetLicense(lic) + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + ipFilters, r, err := th.Client.GetIPFilters(context.Background()) + require.Error(t, err) + require.Nil(t, ipFilters) + require.Equal(t, 501, r.StatusCode) + }) + + t.Run("Feature flag and license but no permission", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + ipFiltering := &mocks.IPFilteringInterface{} + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + + th.App.Srv().SetLicense(lic) + + th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password) + + ipFilters, r, err := th.Client.GetIPFilters(context.Background()) + require.Error(t, err) + require.Nil(t, ipFilters) + require.Equal(t, 403, r.StatusCode) + }) + + t.Run("Feature flag and license and permission", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + ipFiltering := &mocks.IPFilteringInterface{} + ipFiltering.Mock.On("GetIPFilters").Return(&model.AllowedIPRanges{ + model.AllowedIPRange{ + CIDRBlock: "127.0.0.1/32", + Description: "test", + }, + }, nil) + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + + th.App.Srv().SetLicense(lic) + + th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + ipFilters, r, err := th.Client.GetIPFilters(context.Background()) + require.NoError(t, err) + require.NotNil(t, ipFilters) + require.Equal(t, 200, r.StatusCode) + }) +} + +func Test_applyIPFilters(t *testing.T) { + allowedRanges := &model.AllowedIPRanges{ + model.AllowedIPRange{ + CIDRBlock: "127.0.0.1/32", + Description: "test", + }, + } + + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + Cloud: model.NewBool(true), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuEnterprise, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + // Initialize the allowedRanges variable + t.Run("No license returns 501", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + ipFiltering := &mocks.IPFilteringInterface{} + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + + th.App.Srv().RemoveLicense() + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges) + require.Error(t, err) + require.Nil(t, ipFilters) + require.Equal(t, 501, r.StatusCode) + }) + + t.Run("License but no feature flag returns 501", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + ipFiltering := &mocks.IPFilteringInterface{} + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + th.App.Srv().SetLicense(lic) + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges) + require.Error(t, err) + require.Nil(t, ipFilters) + require.Equal(t, 501, r.StatusCode) + }) + + t.Run("feature flag and license but no permission", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + ipFiltering := &mocks.IPFilteringInterface{} + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + th.App.Srv().SetLicense(lic) + + ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges) + require.Error(t, err) + require.Nil(t, ipFilters) + require.Equal(t, 403, r.StatusCode) + }) + + t.Run("Feature flag and license and permission", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + ipFiltering := &mocks.IPFilteringInterface{} + ipFiltering.Mock.On("ApplyIPFilters", mock.Anything).Return(&model.AllowedIPRanges{ + model.AllowedIPRange{ + CIDRBlock: "127.0.0.1/32", + Description: "test", + }, + }, nil) + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + + th.App.Srv().SetLicense(lic) + + th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + ipFilters, r, err := th.Client.ApplyIPFilters(context.Background(), allowedRanges) + require.NoError(t, err) + require.NotNil(t, ipFilters) + require.Equal(t, 200, r.StatusCode) + }) +} + +func Test_getMyIP(t *testing.T) { + lic := &model.License{ + Features: &model.Features{ + CustomPermissionsSchemes: model.NewBool(false), + Cloud: model.NewBool(true), + }, + Customer: &model.Customer{ + Name: "TestName", + Email: "test@example.com", + }, + SkuName: "SKU NAME", + SkuShortName: model.LicenseShortSkuEnterprise, + StartsAt: model.GetMillis() - 1000, + ExpiresAt: model.GetMillis() + 100000, + } + t.Run("No license returns 501", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + ipFiltering := &mocks.IPFilteringInterface{} + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + + th.App.Srv().RemoveLicense() + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + myIP, r, err := th.Client.GetMyIP(context.Background()) + require.Error(t, err) + require.Nil(t, myIP) + require.Equal(t, 501, r.StatusCode) + }) + + t.Run("Licensed, but no feature flag returns 501", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDIPFILTERING", "false") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDIPFILTERING") + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + ipFiltering := &mocks.IPFilteringInterface{} + ipFilteringImpl := th.App.Srv().IPFiltering + defer func() { + th.App.Srv().IPFiltering = ipFilteringImpl + }() + th.App.Srv().IPFiltering = ipFiltering + th.App.Srv().SetLicense(lic) + + myIP, r, err := th.Client.GetMyIP(context.Background()) + require.Error(t, err) + require.Nil(t, myIP) + require.Equal(t, 501, r.StatusCode) + }) +} diff --git a/server/channels/app/app.go b/server/channels/app/app.go index aacc93bf35..e0b9b9bf2f 100644 --- a/server/channels/app/app.go +++ b/server/channels/app/app.go @@ -103,6 +103,11 @@ func (a *App) Saml() einterfaces.SamlInterface { func (a *App) Cloud() einterfaces.CloudInterface { return a.ch.srv.Cloud } + +func (a *App) IPFiltering() einterfaces.IPFilteringInterface { + return a.ch.srv.IPFiltering +} + func (a *App) HTTPService() httpservice.HTTPService { return a.ch.srv.httpService } diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 25efd650db..178635ae3a 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -868,6 +868,7 @@ type AppIface interface { HasPermissionToTeam(c request.CTX, askingUserId string, teamID string, permission *model.Permission) bool HasPermissionToUser(askingUserId string, userID string) bool HasSharedChannel(channelID string) (bool, error) + IPFiltering() einterfaces.IPFilteringInterface ImageProxy() *imageproxy.ImageProxy ImageProxyAdder() func(string) string ImageProxyRemover() (f func(string) string) diff --git a/server/channels/app/email/email.go b/server/channels/app/email/email.go index 21996d66b9..3ef23f74ab 100644 --- a/server/channels/app/email/email.go +++ b/server/channels/app/email/email.go @@ -1275,3 +1275,40 @@ func (es *Service) SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale return nil } + +func (es *Service) SendIPFiltersChangedEmail(email string, initiatingUser *model.User, siteURL, portalURL, locale string, isWorkspaceOwner bool) error { + T := i18n.GetUserTranslations(locale) + + subject := T("api.templates.ip_filters_changed.subject") + + data := es.NewEmailTemplateData(locale) + data.Props["SiteURL"] = siteURL + data.Props["Title"] = T("api.templates.ip_filters_changed.title") + data.Props["SubTitle"] = T("api.templates.ip_filters_changed.subTitle", map[string]any{"InitiatingUsername": initiatingUser.Username, "SiteURL": siteURL}) + data.Props["ButtonURL"] = siteURL + "/admin_console/site_config/ip_filtering" + data.Props["Button"] = T("api.templates.ip_filters_changed.button") + data.Props["TroubleAccessingTitle"] = T("api.templates.ip_filters_changed_footer.title") + data.Props["SendAnEmailTo"] = T("api.templates.ip_filters_changed_footer.send_an_email_to", map[string]any{"InitiatingUserEmail": initiatingUser.Email}) + data.Props["PortalURL"] = portalURL + // If the email we're sending to was the one who initiated the change, we don't want to show their email address as a mailto + if email != initiatingUser.Email { + data.Props["ActorEmail"] = initiatingUser.Email + } + + if isWorkspaceOwner { + data.Props["LogInToCustomerPortal"] = T("api.templates.ip_filters_changed_footer.log_in_to_customer_portal") + } + data.Props["ContactSupport"] = T("api.templates.ip_filters_changed_footer.contact_support") + data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail + + body, err := es.templatesContainer.RenderToString("ip_filters_changed", data) + if err != nil { + return err + } + + if err := es.sendMail(email, subject, body, "PasswordResetEmail"); err != nil { + return err + } + + return nil +} diff --git a/server/channels/app/email/mocks/ServiceInterface.go b/server/channels/app/email/mocks/ServiceInterface.go index 588c3c03a1..8597124ea8 100644 --- a/server/channels/app/email/mocks/ServiceInterface.go +++ b/server/channels/app/email/mocks/ServiceInterface.go @@ -310,6 +310,20 @@ func (_m *ServiceInterface) SendGuestInviteEmails(team *model.Team, channels []* return r0 } +// SendIPFiltersChangedEmail provides a mock function with given fields: _a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner +func (_m *ServiceInterface) SendIPFiltersChangedEmail(_a0 string, userWhoChangedFilter *model.User, siteURL string, portalURL string, locale string, isWorkspaceOwner bool) error { + ret := _m.Called(_a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *model.User, string, string, string, bool) error); ok { + r0 = rf(_a0, userWhoChangedFilter, siteURL, portalURL, locale, isWorkspaceOwner) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SendInviteEmails provides a mock function with given fields: team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin func (_m *ServiceInterface) SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) error { ret := _m.Called(team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin) diff --git a/server/channels/app/email/service.go b/server/channels/app/email/service.go index 14d87a7616..af57bc0e7c 100644 --- a/server/channels/app/email/service.go +++ b/server/channels/app/email/service.go @@ -163,6 +163,7 @@ type ServiceInterface interface { InitEmailBatching() SendChangeUsernameEmail(newUsername, email, locale, siteURL string) error CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error) + SendIPFiltersChangedEmail(email string, userWhoChangedFilter *model.User, siteURL, portalURL, locale string, isWorkspaceOwner bool) error Stop() } diff --git a/server/channels/app/enterprise.go b/server/channels/app/enterprise.go index 4638932bfb..ae3cb01e95 100644 --- a/server/channels/app/enterprise.go +++ b/server/channels/app/enterprise.go @@ -92,6 +92,12 @@ func RegisterNotificationInterface(f func(*App) einterfaces.NotificationInterfac notificationInterface = f } +var ipFilteringInterface func(*App) einterfaces.IPFilteringInterface + +func RegisterIPFilteringInterface(f func(*App) einterfaces.IPFilteringInterface) { + ipFilteringInterface = f +} + func (s *Server) initEnterprise() { if cloudInterface != nil { s.Cloud = cloudInterface(s) diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 7b13ee9216..15bd6138d6 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -11560,6 +11560,23 @@ func (a *OpenTracingAppLayer) HubUnregister(webConn *platform.WebConn) { a.app.HubUnregister(webConn) } +func (a *OpenTracingAppLayer) IPFiltering() einterfaces.IPFilteringInterface { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.IPFiltering") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.IPFiltering() + + return resultVar0 +} + func (a *OpenTracingAppLayer) ImageProxyAdder() func(string) string { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ImageProxyAdder") diff --git a/server/channels/app/permissions_migrations.go b/server/channels/app/permissions_migrations.go index 57e13e9217..fd8052213b 100644 --- a/server/channels/app/permissions_migrations.go +++ b/server/channels/app/permissions_migrations.go @@ -1117,6 +1117,30 @@ func (a *App) getAddChannelReadContentPermissions() (permissionsMap, error) { return t, nil } +func (a *App) getAddIPFilterPermissionsMigration() (permissionsMap, error) { + t := []permissionTransformation{} + + ipFilterPermissionsRead := []string{ + model.PermissionSysconsoleReadIPFilters.Id, + } + + ipFilterPermissionsWrite := []string{ + model.PermissionSysconsoleWriteIPFilters.Id, + } + + t = append(t, permissionTransformation{ + On: permissionOr(isExactRole(model.SystemAdminRoleId)), + Add: ipFilterPermissionsRead, + }) + + t = append(t, permissionTransformation{ + On: permissionOr(isExactRole(model.SystemAdminRoleId)), + Add: ipFilterPermissionsWrite, + }) + + return t, nil +} + // DoPermissionsMigrations execute all the permissions migrations need by the current version. func (a *App) DoPermissionsMigrations() error { return a.Srv().doPermissionsMigrations() @@ -1161,6 +1185,7 @@ func (s *Server) doPermissionsMigrations() error { {Key: model.MigrationKeyAddProductsBoardsPermissions, Migration: a.getProductsBoardsPermissions}, {Key: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Migration: a.getAddCustomUserGroupsPermissionRestore}, {Key: model.MigrationKeyAddReadChannelContentPermissions, Migration: a.getAddChannelReadContentPermissions}, + {Key: model.MigrationKeyAddIPFilteringPermissions, Migration: a.getAddIPFilterPermissionsMigration}, } roles, err := s.Store().Role().GetAll() diff --git a/server/channels/app/server.go b/server/channels/app/server.go index a3b5c4b7ac..7a834f2b80 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -141,7 +141,8 @@ type Server struct { // startSearchEngine bool skipPostInit bool - Cloud einterfaces.CloudInterface + Cloud einterfaces.CloudInterface + IPFiltering einterfaces.IPFilteringInterface tracer *tracing.Tracer @@ -396,6 +397,10 @@ func NewServer(options ...Option) (*Server, error) { s.initJobs() + if ipFilteringInterface != nil { + s.IPFiltering = ipFilteringInterface(app) + } + s.clusterLeaderListenerId = s.AddClusterLeaderChangedListener(func() { mlog.Info("Cluster leader changed. Determining if job schedulers should be running:", mlog.Bool("isLeader", s.IsLeader())) if s.Jobs != nil { diff --git a/server/channels/testlib/store.go b/server/channels/testlib/store.go index f6a28a6490..deea701b16 100644 --- a/server/channels/testlib/store.go +++ b/server/channels/testlib/store.go @@ -72,6 +72,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { systemStore.On("GetByName", model.MigrationKeyAddCustomUserGroupsPermissionRestore).Return(&model.System{Name: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyAddReadChannelContentPermissions).Return(&model.System{Name: model.MigrationKeyAddReadChannelContentPermissions, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyDeleteEmptyDrafts).Return(&model.System{Name: model.MigrationKeyDeleteEmptyDrafts, Value: "true"}, nil) + systemStore.On("GetByName", model.MigrationKeyAddIPFilteringPermissions).Return(&model.System{Name: model.MigrationKeyAddIPFilteringPermissions, Value: "true"}, nil) systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil) systemStore.On("GetByName", "elasticsearch_fix_channel_index_migration").Return(&model.System{Name: "elasticsearch_fix_channel_index_migration", Value: "true"}, nil) diff --git a/server/einterfaces/cloud.go b/server/einterfaces/cloud.go index 9b56dc413e..c447a611c8 100644 --- a/server/einterfaces/cloud.go +++ b/server/einterfaces/cloud.go @@ -53,4 +53,7 @@ type CloudInterface interface { // Used only for when a customer has telemetry disabled. In this scenario, true up review telemetry will be submitted via CWS. SubmitTrueUpReview(userID string, trueUpReviewProfile map[string]any) error + + ApplyIPFilters(userID string, ranges *model.AllowedIPRanges) (*model.AllowedIPRanges, error) + GetIPFilters(userID string) (*model.AllowedIPRanges, error) } diff --git a/server/einterfaces/ip_filtering.go b/server/einterfaces/ip_filtering.go new file mode 100644 index 0000000000..4bdf0333f6 --- /dev/null +++ b/server/einterfaces/ip_filtering.go @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package einterfaces + +import "github.com/mattermost/mattermost/server/public/model" + +type IPFilteringInterface interface { + ApplyIPFilters(allowedIPRanges *model.AllowedIPRanges) (*model.AllowedIPRanges, error) + GetIPFilters() (*model.AllowedIPRanges, error) +} diff --git a/server/einterfaces/mocks/CloudInterface.go b/server/einterfaces/mocks/CloudInterface.go index 5fb6f19646..86d7b2708e 100644 --- a/server/einterfaces/mocks/CloudInterface.go +++ b/server/einterfaces/mocks/CloudInterface.go @@ -15,6 +15,32 @@ type CloudInterface struct { mock.Mock } +// ApplyIPFilters provides a mock function with given fields: userID, ranges +func (_m *CloudInterface) ApplyIPFilters(userID string, ranges *model.AllowedIPRanges) (*model.AllowedIPRanges, error) { + ret := _m.Called(userID, ranges) + + var r0 *model.AllowedIPRanges + var r1 error + if rf, ok := ret.Get(0).(func(string, *model.AllowedIPRanges) (*model.AllowedIPRanges, error)); ok { + return rf(userID, ranges) + } + if rf, ok := ret.Get(0).(func(string, *model.AllowedIPRanges) *model.AllowedIPRanges); ok { + r0 = rf(userID, ranges) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AllowedIPRanges) + } + } + + if rf, ok := ret.Get(1).(func(string, *model.AllowedIPRanges) error); ok { + r1 = rf(userID, ranges) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // BootstrapSelfHostedSignup provides a mock function with given fields: req func (_m *CloudInterface) BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) { ret := _m.Called(req) @@ -343,6 +369,32 @@ func (_m *CloudInterface) GetCloudProducts(userID string, includeLegacyProducts return r0, r1 } +// GetIPFilters provides a mock function with given fields: userID +func (_m *CloudInterface) GetIPFilters(userID string) (*model.AllowedIPRanges, error) { + ret := _m.Called(userID) + + var r0 *model.AllowedIPRanges + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.AllowedIPRanges, error)); ok { + return rf(userID) + } + if rf, ok := ret.Get(0).(func(string) *model.AllowedIPRanges); ok { + r0 = rf(userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AllowedIPRanges) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(userID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetInvoicePDF provides a mock function with given fields: userID, invoiceID func (_m *CloudInterface) GetInvoicePDF(userID string, invoiceID string) ([]byte, string, error) { ret := _m.Called(userID, invoiceID) diff --git a/server/einterfaces/mocks/IPFilteringInterface.go b/server/einterfaces/mocks/IPFilteringInterface.go new file mode 100644 index 0000000000..196fe49406 --- /dev/null +++ b/server/einterfaces/mocks/IPFilteringInterface.go @@ -0,0 +1,82 @@ +// Code generated by mockery v2.23.2. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// IPFilteringInterface is an autogenerated mock type for the IPFilteringInterface type +type IPFilteringInterface struct { + mock.Mock +} + +// ApplyIPFilters provides a mock function with given fields: allowedIPRanges +func (_m *IPFilteringInterface) ApplyIPFilters(allowedIPRanges *model.AllowedIPRanges) (*model.AllowedIPRanges, error) { + ret := _m.Called(allowedIPRanges) + + var r0 *model.AllowedIPRanges + var r1 error + if rf, ok := ret.Get(0).(func(*model.AllowedIPRanges) (*model.AllowedIPRanges, error)); ok { + return rf(allowedIPRanges) + } + if rf, ok := ret.Get(0).(func(*model.AllowedIPRanges) *model.AllowedIPRanges); ok { + r0 = rf(allowedIPRanges) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AllowedIPRanges) + } + } + + if rf, ok := ret.Get(1).(func(*model.AllowedIPRanges) error); ok { + r1 = rf(allowedIPRanges) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetIPFilters provides a mock function with given fields: +func (_m *IPFilteringInterface) GetIPFilters() (*model.AllowedIPRanges, error) { + ret := _m.Called() + + var r0 *model.AllowedIPRanges + var r1 error + if rf, ok := ret.Get(0).(func() (*model.AllowedIPRanges, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *model.AllowedIPRanges); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AllowedIPRanges) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewIPFilteringInterface interface { + mock.TestingT + Cleanup(func()) +} + +// NewIPFilteringInterface creates a new instance of IPFilteringInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewIPFilteringInterface(t mockConstructorTestingTNewIPFilteringInterface) *IPFilteringInterface { + mock := &IPFilteringInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/i18n/en.json b/server/i18n/en.json index 8000bd55f0..19c0282d8c 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1625,6 +1625,22 @@ "id": "api.context.invitation_expired.error", "translation": "Invitation is expired." }, + { + "id": "api.context.ip_filtering.apply_ip_filters.app_error", + "translation": "An error has occurred while applying IP Filters" + }, + { + "id": "api.context.ip_filtering.get_ip_filters.app_error", + "translation": "An error has occurred while fetching IP Filters" + }, + { + "id": "api.context.ip_filtering.get_my_ip.failed", + "translation": "An error has occurred while fetching the client's IP address" + }, + { + "id": "api.context.ip_filtering.not_available.app_error", + "translation": "IP Filtering is not available on this server" + }, { "id": "api.context.json_encoding.app_error", "translation": "Error encoding JSON." @@ -3706,6 +3722,38 @@ "id": "api.templates.invite_team_and_channels_subject", "translation": "[{{ .SiteName }}] {{ .SenderName }} invited you to join {{ .ChannelsLen }} channels on the {{ .TeamDisplayName }} Team" }, + { + "id": "api.templates.ip_filters_changed.button", + "translation": "Review changes" + }, + { + "id": "api.templates.ip_filters_changed.subTitle", + "translation": "@{{ .InitiatingUsername }} changed the IP filtering settings for your workspace at the URL: {{ .SiteURL }}" + }, + { + "id": "api.templates.ip_filters_changed.subject", + "translation": "Changes to Your Workspace's IP Filters" + }, + { + "id": "api.templates.ip_filters_changed.title", + "translation": "IP filtering changes for your workspace" + }, + { + "id": "api.templates.ip_filters_changed_footer.contact_support", + "translation": "Contact support" + }, + { + "id": "api.templates.ip_filters_changed_footer.log_in_to_customer_portal", + "translation": "Log in to the customer portal to reset IP filtering" + }, + { + "id": "api.templates.ip_filters_changed_footer.send_an_email_to", + "translation": "Send an email to {{ .InitiatingUserEmail }}" + }, + { + "id": "api.templates.ip_filters_changed_footer.title", + "translation": "Having trouble accessing your workspace?" + }, { "id": "api.templates.license_up_for_renewal_contact_sales", "translation": "Contact sales" diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 53306f4aef..ce06429f7f 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -555,6 +555,10 @@ func (c *Client4) sharedChannelsRoute() string { return "/sharedchannels" } +func (c *Client4) ipFiltersRoute() string { + return "/ip_filtering" +} + func (c *Client4) permissionsRoute() string { return "/permissions" } @@ -8028,6 +8032,52 @@ func (c *Client4) GetProductLimits(ctx context.Context) (*ProductLimits, *Respon return productLimits, BuildResponse(r), nil } +func (c *Client4) GetIPFilters(ctx context.Context) (*AllowedIPRanges, *Response, error) { + r, err := c.DoAPIGet(ctx, c.ipFiltersRoute(), "") + if err != nil { + return nil, BuildResponse(r), err + } + + defer closeBody(r) + + var allowedIPRanges *AllowedIPRanges + json.NewDecoder(r.Body).Decode(&allowedIPRanges) + return allowedIPRanges, BuildResponse(r), nil +} + +func (c *Client4) ApplyIPFilters(ctx context.Context, allowedRanges *AllowedIPRanges) (*AllowedIPRanges, *Response, error) { + payload, err := json.Marshal(allowedRanges) + if err != nil { + return nil, nil, NewAppError("ApplyIPFilters", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPostBytes(ctx, c.ipFiltersRoute(), payload) + if err != nil { + return nil, BuildResponse(r), err + } + + defer closeBody(r) + + var allowedIPRanges *AllowedIPRanges + json.NewDecoder(r.Body).Decode(&allowedIPRanges) + + return allowedIPRanges, BuildResponse(r), nil +} + +func (c *Client4) GetMyIP(ctx context.Context) (*GetIPAddressResponse, *Response, error) { + r, err := c.DoAPIGet(ctx, c.ipFiltersRoute()+"/my_ip", "") + if err != nil { + return nil, BuildResponse(r), err + } + + defer closeBody(r) + + var response *GetIPAddressResponse + json.NewDecoder(r.Body).Decode(&response) + + return response, BuildResponse(r), nil +} + func (c *Client4) CreateCustomerPayment(ctx context.Context) (*StripeSetupIntent, *Response, error) { r, err := c.DoAPIPost(ctx, c.cloudRoute()+"/payment", "") if err != nil { diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 04f4ba9b74..a34f07d851 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -44,7 +44,8 @@ type FeatureFlags struct { StreamlinedMarketplace bool - ConsumePostHook bool + CloudIPFiltering bool + ConsumePostHook bool } func (f *FeatureFlags) SetDefaults() { @@ -60,6 +61,7 @@ func (f *FeatureFlags) SetDefaults() { f.CloudReverseTrial = false f.EnableExportDirectDownload = false f.StreamlinedMarketplace = true + f.CloudIPFiltering = false f.ConsumePostHook = false } diff --git a/server/public/model/ip_filtering.go b/server/public/model/ip_filtering.go new file mode 100644 index 0000000000..75dda05697 --- /dev/null +++ b/server/public/model/ip_filtering.go @@ -0,0 +1,20 @@ +package model + +type AllowedIPRanges []AllowedIPRange + +type AllowedIPRange struct { + CIDRBlock string `json:"cidr_block"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + OwnerID string `json:"owner_id"` +} + +func (air *AllowedIPRanges) Auditable() map[string]interface{} { + return map[string]interface{}{ + "AllowedIPRanges": air, + } +} + +type GetIPAddressResponse struct { + IP string `json:"ip"` +} diff --git a/server/public/model/migration.go b/server/public/model/migration.go index faa33a0670..12692e6f83 100644 --- a/server/public/model/migration.go +++ b/server/public/model/migration.go @@ -44,4 +44,5 @@ const ( MigrationKeyElasticsearchFixChannelIndex = "elasticsearch_fix_channel_index_migration" MigrationKeyS3Path = "s3_path_migration" MigrationKeyDeleteEmptyDrafts = "delete_empty_drafts_migration" + MigrationKeyAddIPFilteringPermissions = "add_ip_filtering_permissions" ) diff --git a/server/public/model/permission.go b/server/public/model/permission.go index 88c35798fb..db68995d64 100644 --- a/server/public/model/permission.go +++ b/server/public/model/permission.go @@ -267,6 +267,9 @@ var PermissionSysconsoleWriteSitePublicLinks *Permission var PermissionSysconsoleReadSiteNotices *Permission var PermissionSysconsoleWriteSiteNotices *Permission +var PermissionSysconsoleReadIPFilters *Permission +var PermissionSysconsoleWriteIPFilters *Permission + var PermissionSysconsoleReadAuthentication *Permission var PermissionSysconsoleWriteAuthentication *Permission @@ -1646,6 +1649,20 @@ func initializePermissions() { PermissionScopeSystem, } + PermissionSysconsoleReadIPFilters = &Permission{ + "sysconsole_read_site_ip_filters", + "", + "", + PermissionScopeSystem, + } + + PermissionSysconsoleWriteIPFilters = &Permission{ + "sysconsole_write_site_ip_filters", + "", + "", + PermissionScopeSystem, + } + // Deprecated PermissionSysconsoleReadAuthentication = &Permission{ "sysconsole_read_authentication", @@ -2160,6 +2177,7 @@ func initializePermissions() { PermissionSysconsoleReadExperimentalFeatureFlags, PermissionSysconsoleReadExperimentalBleve, PermissionSysconsoleReadProductsBoards, + PermissionSysconsoleReadIPFilters, } SysconsoleWritePermissions = []*Permission{ @@ -2218,6 +2236,7 @@ func initializePermissions() { PermissionSysconsoleWriteExperimentalFeatureFlags, PermissionSysconsoleWriteExperimentalBleve, PermissionSysconsoleWriteProductsBoards, + PermissionSysconsoleWriteIPFilters, } SystemScopedPermissionsMinusSysconsole := []*Permission{ diff --git a/server/templates/cloud_14_day_arrears.html b/server/templates/cloud_14_day_arrears.html index d5115d151e..ebd1e3dcc1 100644 --- a/server/templates/cloud_14_day_arrears.html +++ b/server/templates/cloud_14_day_arrears.html @@ -296,6 +296,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/cloud_30_day_arrears.html b/server/templates/cloud_30_day_arrears.html index 40cc01ef49..47ef5b5c84 100644 --- a/server/templates/cloud_30_day_arrears.html +++ b/server/templates/cloud_30_day_arrears.html @@ -296,6 +296,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/cloud_45_day_arrears.html b/server/templates/cloud_45_day_arrears.html index 58d7859d95..bf4d45e85e 100644 --- a/server/templates/cloud_45_day_arrears.html +++ b/server/templates/cloud_45_day_arrears.html @@ -296,6 +296,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/cloud_7_day_arrears.html b/server/templates/cloud_7_day_arrears.html index 712b29fa37..70230a6157 100644 --- a/server/templates/cloud_7_day_arrears.html +++ b/server/templates/cloud_7_day_arrears.html @@ -296,6 +296,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/cloud_90_day_arrears.html b/server/templates/cloud_90_day_arrears.html index 5bdeacbab7..2a5ac6913f 100644 --- a/server/templates/cloud_90_day_arrears.html +++ b/server/templates/cloud_90_day_arrears.html @@ -296,6 +296,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/cloud_upgrade_confirmation.html b/server/templates/cloud_upgrade_confirmation.html index 27f4fca15c..f676b61c46 100644 --- a/server/templates/cloud_upgrade_confirmation.html +++ b/server/templates/cloud_upgrade_confirmation.html @@ -286,6 +286,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/invite_body.html b/server/templates/invite_body.html index 8a3b232b65..dc6fe22076 100644 --- a/server/templates/invite_body.html +++ b/server/templates/invite_body.html @@ -306,6 +306,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/ip_filters_changed.html b/server/templates/ip_filters_changed.html new file mode 100644 index 0000000000..af27433b86 --- /dev/null +++ b/server/templates/ip_filters_changed.html @@ -0,0 +1,580 @@ +{{define "ip_filters_changed"}} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + +
+
{{.Props.Title}}
+
+
{{.Props.SubTitle}}
+
+ + + + +
+ + {{.Props.Button}} + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + {{if .Props.ActorEmail}} + + + + + + + {{end}}{{ if .Props.LogInToCustomerPortal}} + + + + + + + {{end}} + + + + +
+
{{.Props.TroubleAccessingTitle}}
+
+ + + + +
+ + {{.Props.SendAnEmailTo}} + +
+
+

+

+ +
+ + + + +
+ + {{.Props.LogInToCustomerPortal}} + +
+
+

+

+ +
+ + + + +
+ + {{.Props.ContactSupport}} + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{{.Props.Organization}} + {{.Props.FooterV2}} +
+
+
+ +
+
+ +
+
+ +
+ + + + +{{end}} diff --git a/server/templates/ip_filters_changed.mjml b/server/templates/ip_filters_changed.mjml new file mode 100644 index 0000000000..e392a59e8e --- /dev/null +++ b/server/templates/ip_filters_changed.mjml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + {{.Props.TroubleAccessingTitle}} + + {{if .Props.ActorEmail}} + + {{.Props.SendAnEmailTo}} + + + {{end}} + {{ if .Props.LogInToCustomerPortal}} + + {{.Props.LogInToCustomerPortal}} + + + {{end}} + + {{.Props.ContactSupport}} + + + + + + + \ No newline at end of file diff --git a/server/templates/license_up_for_renewal.html b/server/templates/license_up_for_renewal.html index d05cc8d8be..d9daa4cce4 100644 --- a/server/templates/license_up_for_renewal.html +++ b/server/templates/license_up_for_renewal.html @@ -286,6 +286,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/messages_notification.html b/server/templates/messages_notification.html index ba2aaee252..ba94db3b3e 100644 --- a/server/templates/messages_notification.html +++ b/server/templates/messages_notification.html @@ -306,6 +306,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/partials/style.css b/server/templates/partials/style.css index 3c72f05ea0..8759cddb23 100644 --- a/server/templates/partials/style.css +++ b/server/templates/partials/style.css @@ -193,6 +193,10 @@ padding: 0px 0px 0px 12px !important; } +.divider { + opacity: 12%; +} + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/payment_failed_body.html b/server/templates/payment_failed_body.html index 81060f9e37..97cdea917a 100644 --- a/server/templates/payment_failed_body.html +++ b/server/templates/payment_failed_body.html @@ -296,6 +296,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/reset_body.html b/server/templates/reset_body.html index ab2b11be32..3ead187da7 100644 --- a/server/templates/reset_body.html +++ b/server/templates/reset_body.html @@ -286,6 +286,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/verify_body.html b/server/templates/verify_body.html index a94df5f287..aeeb616400 100644 --- a/server/templates/verify_body.html +++ b/server/templates/verify_body.html @@ -286,6 +286,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/server/templates/welcome_body.html b/server/templates/welcome_body.html index 5e85097a31..fc8732dbc3 100644 --- a/server/templates/welcome_body.html +++ b/server/templates/welcome_body.html @@ -286,6 +286,10 @@ padding: 0px 0px 0px 12px !important; } + .divider { + opacity: 12%; + } + @media all and (min-width: 541px) { .emailBody { padding: 32px !important; diff --git a/webapp/channels/jest.config.js b/webapp/channels/jest.config.js index 9f07905e88..c7972e9f3d 100644 --- a/webapp/channels/jest.config.js +++ b/webapp/channels/jest.config.js @@ -38,7 +38,7 @@ const config = { ['jest-junit', {outputDirectory: 'build', outputName: 'test-results.xml'}], ], transformIgnorePatterns: [ - 'node_modules/(?!react-native|react-router|p-queue|p-timeout|@mattermost/compass-components|@mattermost/compass-icons)', + 'node_modules/(?!react-native|react-router|p-queue|p-timeout|@mattermost/compass-components|@mattermost/compass-icons|cidr-regex|ip-regex)', ], setupFiles: ['jest-canvas-mock'], setupFilesAfterEnv: ['/src/tests/setup_jest.ts'], diff --git a/webapp/channels/package.json b/webapp/channels/package.json index dbba461960..9b412f53c9 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -45,6 +45,7 @@ "hoist-non-react-statics": "3.3.2", "html-to-react": "1.6.0", "inobounce": "0.2.1", + "ipaddr.js": "2.1.0", "katex": "0.16.3", "key-mirror": "1.0.1", "localforage": "1.10.0", diff --git a/webapp/channels/src/actions/admin_actions.jsx b/webapp/channels/src/actions/admin_actions.jsx index 366f8cbe5e..a3bfecd744 100644 --- a/webapp/channels/src/actions/admin_actions.jsx +++ b/webapp/channels/src/actions/admin_actions.jsx @@ -91,6 +91,33 @@ export async function samlCertificateStatus(success, error) { } } +export async function getIPFilters(success, error) { + const {data, error: err} = await AdminActions.getIPFilters()(dispatch, getState); + if (data && success) { + success(data); + } else if (err && error) { + error(err); + } +} + +export async function getCurrentIP(success, error) { + const {data, error: err} = await AdminActions.getCurrentIP()(dispatch, getState); + if (data && success) { + success(data); + } else if (err && error) { + error(err); + } +} + +export async function applyIPFilters(ipList, success, error) { + const {data, error: err} = await AdminActions.applyIPFilters(ipList)(dispatch, getState); + if (data && success) { + success(data); + } else if (err && error) { + error(err); + } +} + export function getOAuthAppInfo(clientId) { return bindClientFunc({ clientFunc: Client4.getOAuthAppInfo, diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 24d7489b4f..7aada60a25 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -73,6 +73,7 @@ import { import FeatureFlags from './feature_flags'; import GroupDetails from './group_settings/group_details'; import GroupSettings from './group_settings/group_settings'; +import IPFiltering from './ip_filtering'; import LicenseSettings from './license_settings'; import MessageExportSettings from './message_export_settings'; import OpenIdConvert from './openid_convert'; @@ -3289,6 +3290,20 @@ const AdminDefinition: AdminDefinitionType = { ], }, }, + ip_filtering: { + url: 'site_config/ip_filtering', + title: t('admin.sidebar.ip_filtering'), + title_default: 'IP Filtering', + isHidden: it.not(it.all(it.configIsTrue('FeatureFlags', 'CloudIPFiltering'), it.licensedForSku('enterprise'))), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.IP_FILTERING)), + searchableStrings: [ + 'admin.sidebar.ip_filtering', + ], + schema: { + id: 'IPFiltering', + component: IPFiltering, + }, + }, }, }, authentication: { diff --git a/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.scss b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.scss new file mode 100644 index 0000000000..c2ebdd633c --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.scss @@ -0,0 +1,202 @@ +.IPFilteringAddOrEditModal { + .modal-dialog { + position: absolute; + top: 50%; + left: 50%; + width: 600px; + border: 1 px solid rgba(var(--center-channel-color-rgb), 0.08); + margin: auto; + border-radius: 12px; + transform: translate(-50%, -50%) !important; + } + + .modal-content { + border-radius: 12px; + } + + .modal-header { + .close { + &:hover { + background-color: + rgba( + var(--center-channel-color-rgb), + 0.08 + ); + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + &:active { + background-color: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + } + + top: 26px; + right: 26px; + width: 24px; + height: 24px; + border-radius: 4px; + color: rgba(var(--center-channel-color-rgb), 0.56) !important; + font-family: + 'Open Sans', + sans-serif; + font-size: 32px; + font-weight: 400; + } + + .title { + font-family: Metropolis, sans-serif; + font-size: 22px; + font-style: normal; + font-weight: 600; + line-height: 28px; + text-align: left; + } + + display: block; + min-height: 48px; + padding-top: 26px; + padding-right: 32px; + padding-bottom: 0; + padding-left: 32px; + border: 0; + background: var(--center-channel-bg) !important; + border-radius: 12px; + color: var(--center-channel-color); + } + + .modal-body { + display: flex; + overflow: hidden; + width: 100%; + flex-direction: column; + padding-top: 24px; + padding-right: 32px; + padding-bottom: 26px; + padding-left: 32px; + + .current_ip_notice { + display: flex; + max-width: 536px; + height: 52px; + border: 1px rgba(87, 158, 255, 0.16) solid; + background: rgba(87, 158, 255, 0.08); + border-radius: 4px; + + .Content { + display: flex; + width: 536px; + height: 52px; + padding: 16px; + color: var(--center-channel-color, #3f4350); + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 600; + gap: 12px; + line-height: 20px; + + span { + display: flex; + height: 20px; + align-items: center; + line-height: 20px; + + svg { + width: 20px; + height: 20px; + margin-right: 12px; + fill: #5d89ea; + } + } + } + } + + .inputs { + color: var(--center-channel-color, #3f4350); + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 20px; + + > div { + margin-top: 24px; + + .Input_container { + margin-top: 8px; + } + + input { + font-weight: normal; + } + } + + > p { + margin-top: 8px; + font-weight: 400; + } + } + + .buttons { + margin-top: 32px; + text-align: right; + + .confirm-btn { + &:hover, + &:active, + &:focus, + &:active:focus { + background: + linear-gradient(0deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.16)), + var(--button-bg); + } + + height: 40px; + flex: none; + padding: 12px 20px; + border: none; + margin-left: 8px; + background: var(--button-bg); + border-radius: 4px; + color: var(--sys-button-color); + font-size: 14px; + font-weight: 600; + line-height: 14px; + } + } + } + + .modal-footer { + padding: 24px 32px; + border-top: none; + border-radius: 12px; + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 20px; + + .btn-cancel { + padding: 10px 20px; + border: none; + background: var(--button-bg-8, rgba(28, 88, 217, 0.08)); + border-radius: 4px; + color: var(--button-bg, #1c58d9); + } + + .btn-save { + &:disabled { + background: rgba(63, 67, 80, 0.08); + color: rgba(63, 67, 80, 0.32); + cursor: not-allowed; + } + + padding: 10px 20px; + border: none; + margin-left: 8px; + background: var(--button-bg, #1c58d9); + border-radius: 4px; + color: var(--button-color, #fff); + } + } +} diff --git a/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.test.tsx new file mode 100644 index 0000000000..4ee51e6bb6 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.test.tsx @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, waitFor} from '@testing-library/react'; +import React from 'react'; + +import type {AllowedIPRange} from '@mattermost/types/config'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import IPFilteringAddOrEditModal from './add_edit_ip_filter_modal'; + +jest.mock('components/external_link', () => { + return jest.fn().mockImplementation(({children, ...props}) => { + return {children}; + }); +}); + +describe('IPFilteringAddOrEditModal', () => { + const onExited = jest.fn(); + const onSave = jest.fn(); + const existingRange: AllowedIPRange = { + cidr_block: '192.168.0.0/16', + description: 'Test IP Filter', + enabled: true, + owner_id: '', + }; + const currentIP = '192.168.0.1'; + + const baseProps = { + onExited, + onSave, + existingRange, + currentIP, + }; + + test('renders the modal with the correct title when an existingRange is provided', () => { + const {getByText} = renderWithContext( + , + ); + + expect(getByText('Edit IP Filter')).toBeInTheDocument(); + }); + + test('renders the modal with the correct title when an existingRange is omitted (ie, Add Modal)', () => { + const {getByText} = renderWithContext( + , + ); + + expect(getByText('Add IP Filter')).toBeInTheDocument(); + }); + + test('renders the modal with the correct inputs and values', () => { + const {getByLabelText} = renderWithContext( + , + ); + + expect(getByLabelText('Enter a name for this rule')).toHaveValue('Test IP Filter'); + expect(getByLabelText('Enter IP Range')).toHaveValue('192.168.0.0/16'); + }); + + test('calls the onSave function with the correct values when the Save button is clicked', async () => { + const {getByLabelText, getByTestId} = renderWithContext( + , + ); + + fireEvent.change(getByLabelText('Enter a name for this rule'), {target: {value: 'Test IP Filter 2'}}); + fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: '10.0.0.0/8'}}); + fireEvent.click(getByTestId('save-add-edit-button')); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ + cidr_block: '10.0.0.0/8', + description: 'Test IP Filter 2', + enabled: true, + owner_id: '', + }, existingRange); + expect(onExited).toHaveBeenCalled(); + }); + }); + + test('calls the onSave function with the correct values when the Save button is clicked for a new IP filter', async () => { + const {getByLabelText, getByTestId} = renderWithContext( + , + ); + + fireEvent.change(getByLabelText('Enter a name for this rule'), {target: {value: 'Test IP Filter 2'}}); + fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: '10.0.0.0/8'}}); + fireEvent.click(getByTestId('save-add-edit-button')); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ + cidr_block: '10.0.0.0/8', + description: 'Test IP Filter 2', + enabled: true, + owner_id: '', + }); + expect(onExited).toHaveBeenCalled(); + }); + }); + + test('displays an error message when an invalid CIDR is entered', async () => { + const {getByLabelText, getByTestId, getByText} = renderWithContext( + , + ); + + fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: 'invalid-cidr'}}); + fireEvent.blur(getByLabelText('Enter IP Range')); + fireEvent.click(getByTestId('save-add-edit-button')); + + await waitFor(() => { + expect(getByText('Invalid CIDR address range')).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + expect(onExited).not.toHaveBeenCalled(); + }); + }); + + test('disables the Save button when an invalid CIDR is entered', () => { + const {getByLabelText, getByTestId} = renderWithContext( + , + ); + + fireEvent.change(getByLabelText('Enter IP Range'), {target: {value: 'invalid-cidr'}}); + fireEvent.blur(getByLabelText('Enter IP Range')); + + expect(getByTestId('save-add-edit-button')).toBeDisabled(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.tsx b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.tsx new file mode 100644 index 0000000000..d663cd68cf --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/add_edit_ip_filter_modal.tsx @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {InformationOutlineIcon} from '@mattermost/compass-icons/components'; +import type {AllowedIPRange} from '@mattermost/types/config'; + +import ExternalLink from 'components/external_link'; +import type {CustomMessageInputType} from 'components/widgets/inputs/input/input'; +import Input from 'components/widgets/inputs/input/input'; + +import './add_edit_ip_filter_modal.scss'; +import {validateCIDR} from './ip_filtering_utils'; + +type Props = { + onExited: () => void; + onSave: (allowedIPRange: AllowedIPRange, oldIPRange?: AllowedIPRange) => void; + existingRange?: AllowedIPRange; + currentIP?: string; +} + +export default function IPFilteringAddOrEditModal({onExited, onSave, existingRange, currentIP}: Props) { + const {formatMessage} = useIntl(); + const [name, setName] = useState(existingRange?.description || ''); + const [CIDR, setCIDR] = useState(existingRange?.cidr_block || ''); + + const [CIDRError, setCIDRError] = useState(null); + + const handleSave = () => { + const allowedIPRange: AllowedIPRange = { + cidr_block: CIDR, + description: name, + enabled: true, + owner_id: '', + }; + + if (existingRange) { + onSave(allowedIPRange, existingRange); + } else { + onSave(allowedIPRange); + } + + onExited(); + }; + + const handleCIDRChange = (e: React.ChangeEvent) => { + const cidr = e.target.value; + setCIDR(cidr); + setCIDRError(null); + }; + + const validateCIDRInput = () => { + if (!validateCIDR(CIDR)) { + setCIDRError({type: 'error', value: 'Invalid CIDR address range'}); + } + }; + + return ( + + +
+ {existingRange?.cidr_block ? formatMessage({id: 'admin.ip_filtering.edit_ip_filter', defaultMessage: 'Edit IP Filter'}) : formatMessage({id: 'admin.ip_filtering.add_ip_filter', defaultMessage: 'Add IP Filter'})} +
+
+ +
+
+
+ {formatMessage({id: 'admin.ip_filtering.your_current_ip_is', defaultMessage: 'Your current IP address is {ip}'}, {ip: currentIP})} +
+
+
+
+ {formatMessage({id: 'admin.ip_filtering.name', defaultMessage: 'Name'})} + setName(e.target.value)} + value={name} + placeholder={formatMessage({id: 'admin.ip_filtering.rule_name_placeholder', defaultMessage: 'Enter a name for this rule'})} + required={true} + useLegend={false} + /> +
+
{formatMessage({id: 'admin.ip_filtering.allow_following_range', defaultMessage: 'Allow the following range of IP Addresses'})} + +
+

+ More info'} + values={{ + link: (msg) => ( + + {msg} + + ), + }} + /> +

+
+
+
+ + + + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.scss b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.scss new file mode 100644 index 0000000000..e79d67d07c --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.scss @@ -0,0 +1,109 @@ +.DeleteConfirmationModal { + .modal-dialog { + position: absolute; + top: 50%; + left: 50%; + width: 600px; + border: 1 px solid rgba(var(--center-channel-color-rgb), 0.08); + margin: auto; + border-radius: 12px; + transform: translate(-50%, -50%) !important; + } + + .modal-content { + border-radius: 12px; + } + + .modal-header { + .close { + &:hover { + background-color: + rgba( + var(--center-channel-color-rgb), + 0.08 + ); + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + &:active { + background-color: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + } + + top: 26px; + right: 26px; + width: 24px; + height: 24px; + border-radius: 4px; + color: rgba(var(--center-channel-color-rgb), 0.56) !important; + font-family: + 'Open Sans', + sans-serif; + font-size: 32px; + font-weight: 400; + } + + .title { + font-family: Metropolis, sans-serif; + font-size: 22px; + font-style: normal; + font-weight: 600; + line-height: 28px; + text-align: left; + } + + display: block; + min-height: 48px; + padding-top: 26px; + padding-bottom: 0; + padding-left: 32px; + border: 0; + background: var(--center-channel-bg) !important; + border-radius: 12px; + color: var(--center-channel-color); + } + + .modal-body { + overflow: hidden; + width: 100%; + flex-direction: column; + padding-top: 24px; + padding-right: 32px; + padding-bottom: 26px; + padding-left: 32px; + color: var(--center-channel-color, #3f4350); + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .modal-footer { + padding: 24px 32px; + border-top: none; + border-radius: 12px; + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 20px; + + .btn-cancel { + padding: 10px 20px; + border: none; + background: var(--button-bg-8, rgba(28, 88, 217, 0.08)); + border-radius: 4px; + color: var(--button-bg, #1c58d9); + } + + .btn-delete { + padding: 10px 20px; + border: none; + margin-left: 8px; + background: var(--denim-status-do-not-disturb, #d24b4e); + border-radius: 4px; + color: var(--button-color, #fff); + } + } +} diff --git a/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.test.tsx new file mode 100644 index 0000000000..ee1fe18c9a --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.test.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {render, fireEvent, waitFor} from '@testing-library/react'; +import React from 'react'; + +import DeleteConfirmationModal from './delete_confirmation'; + +describe('DeleteConfirmationModal', () => { + const onExited = jest.fn(); + const onConfirm = jest.fn(); + const filterToDelete = { + cidr_block: '192.168.0.0/16', + description: 'Test IP Filter', + enabled: true, + owner_id: '', + }; + + const baseProps = { + onExited, + onConfirm, + filterToDelete, + }; + + test('renders the modal with the correct title', () => { + const {getByText} = render( + , + ); + + expect(getByText('Delete IP Filter')).toBeInTheDocument(); + }); + + test('renders the modal with the correct filter name in description', () => { + const {getByText} = render( + , + ); + expect(getByText('Test IP Filter')).toBeInTheDocument(); + }); + + test('calls the onClose function when the Cancel button is clicked', () => { + const {getByText} = render( + , + ); + + fireEvent.click(getByText('Cancel')); + + expect(onExited).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + test('calls the onConfirm function with the correct filter when the Delete filter button is clicked', async () => { + const {getByText} = render( + , + ); + + fireEvent.click(getByText('Delete filter')); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith(filterToDelete); + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.tsx b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.tsx new file mode 100644 index 0000000000..f7f7e6020c --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/delete_confirmation.tsx @@ -0,0 +1,59 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import {useIntl} from 'react-intl'; + +import type {AllowedIPRange} from '@mattermost/types/config'; + +import './delete_confirmation.scss'; + +type Props = { + onExited: () => void; + onConfirm?: (filter: AllowedIPRange) => void; + filterToDelete?: AllowedIPRange; +} + +export default function DeleteConfirmationModal({onExited, onConfirm, filterToDelete}: Props) { + const {formatMessage} = useIntl(); + return ( + + +
+ {formatMessage({id: 'admin.ip_filtering.delete_confirmation_title', defaultMessage: 'Delete IP Filter'})} +
+
+ + {formatMessage({ + id: 'admin.ip_filtering.delete_confirmation_body', + defaultMessage: 'Are you sure you want to delete IP filter {filter}? Users with IP addresses outside of this range won\'t be able to access the workspace when IP Filtering is enabled', + }, + {filter: ({filterToDelete?.description})}, + )} + + + + + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section.test.tsx new file mode 100644 index 0000000000..eabd7af23a --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section.test.tsx @@ -0,0 +1,122 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, fireEvent} from '@testing-library/react'; +import React from 'react'; + +import type {AllowedIPRange} from '@mattermost/types/config'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import EditSection from './'; + +describe('EditSection', () => { + const ipFilters = [ + { + cidr_block: '192.168.0.0/24', + description: 'Test Filter', + }, + ] as AllowedIPRange[]; + const currentUsersIP = '192.168.0.1'; + const setShowAddModal = jest.fn(); + const setEditFilter = jest.fn(); + const handleConfirmDeleteFilter = jest.fn(); + const currentIPIsInRange = true; + + const baseProps = { + ipFilters, + currentUsersIP, + setShowAddModal, + setEditFilter, + handleConfirmDeleteFilter, + currentIPIsInRange, + }; + + test('renders the component', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Allowed IP Addresses')).toBeInTheDocument(); + expect(screen.getByText('Create rules to allow access to the workspace for specified IP addresses only.')).toBeInTheDocument(); + expect(screen.getByText('If no rules are added, all IP addresses will be allowed.')).toBeInTheDocument(); + expect(screen.getByText('Add Filter')).toBeInTheDocument(); + expect(screen.getByText('Filter Name')).toBeInTheDocument(); + expect(screen.getByText('IP Address Range')).toBeInTheDocument(); + expect(screen.getByText('Test Filter')).toBeInTheDocument(); + expect(screen.getByText('192.168.0.0/24')).toBeInTheDocument(); + }); + + test('clicking the Add Filter button calls setShowAddModal', () => { + renderWithContext( + , + ); + + fireEvent.click(screen.getByText('Add Filter')); + + expect(setShowAddModal).toHaveBeenCalledTimes(1); + expect(setShowAddModal).toHaveBeenCalledWith(true); + }); + + test('clicking the Edit button calls setEditFilter', () => { + renderWithContext( + , + ); + + fireEvent.mouseEnter(screen.getByText('Test Filter')); + fireEvent.click(screen.getByRole('button', { + name: /Edit/i, + })); + + expect(setEditFilter).toHaveBeenCalledTimes(1); + expect(setEditFilter).toHaveBeenCalledWith(ipFilters[0]); + }); + + test('clicking the Delete button calls handleConfirmDeleteFilter', () => { + renderWithContext( + , + ); + + fireEvent.mouseEnter(screen.getByText('Test Filter')); + fireEvent.click(screen.getByRole('button', { + name: /Delete/i, + })); + + expect(handleConfirmDeleteFilter).toHaveBeenCalledTimes(1); + expect(handleConfirmDeleteFilter).toHaveBeenCalledWith(ipFilters[0]); + }); + + test('displays an error panel if current IP is not in range', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Your IP address 192.168.1.1 is not included in your allowed IP address rules.')).toBeInTheDocument(); + expect(screen.getByText('Include your IP address in at least one of the rules below to continue.')).toBeInTheDocument(); + expect(screen.getByText('Add your IP address')).toBeInTheDocument(); + }); + + test('displays a message if no filters are added', () => { + renderWithContext( + , + ); + + expect(screen.getByText('No IP filtering rules added')).toBeInTheDocument(); + expect(screen.getByText('Add a filter')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_edit_table_row.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_edit_table_row.tsx new file mode 100644 index 0000000000..14343bbe2e --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_edit_table_row.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import { + PencilOutlineIcon, + TrashCanOutlineIcon, +} from '@mattermost/compass-icons/components'; +import type {AllowedIPRange} from '@mattermost/types/config'; + +import OverlayTrigger from 'components/overlay_trigger'; +import Tooltip from 'components/tooltip'; + +type EditTableRowProps = { + allowedIPRange: AllowedIPRange; + index: number; + handleRowMouseEnter: (index: number) => void; + handleRowMouseLeave: () => void; + setEditFilter: (filter: AllowedIPRange) => void; + handleConfirmDeleteFilter: (filter: AllowedIPRange) => void; + hoveredRow: number | null; +}; + +const EditTableRow = ({ + allowedIPRange, + index, + handleRowMouseEnter, + handleRowMouseLeave, + setEditFilter, + handleConfirmDeleteFilter, + hoveredRow, +}: EditTableRowProps) => { + const {formatMessage} = useIntl(); + const editTooltip = {formatMessage({id: 'admin.ip_filtering.edit', defaultMessage: 'Edit'})}; + const deleteTooltip = {formatMessage({id: 'admin.ip_filtering.delete', defaultMessage: 'Delete'})}; + return ( +
handleRowMouseEnter(index)} + onMouseLeave={handleRowMouseLeave} + > +
{allowedIPRange.description}
+
{allowedIPRange.cidr_block}
+
+ {hoveredRow === index && ( + <> + +
setEditFilter(allowedIPRange)} + > + +
+
+ +
handleConfirmDeleteFilter(allowedIPRange)} + > + +
+
+ + )} +
+
+ ); +}; + +export default EditTableRow; diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_header.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_header.tsx new file mode 100644 index 0000000000..80be7a7611 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_header.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import IPNotInRangeErrorPanel from './edit_section_ip_not_in_range_panel'; + +type EditSectionHeaderProps = { + setShowAddModal: (show: boolean) => void; + currentIPIsInRange: boolean; + currentUsersIP: string | null; +}; + +const EditSectionHeader = ({ + setShowAddModal, + currentIPIsInRange, + currentUsersIP, +}: EditSectionHeaderProps) => ( +
+
+
+
+
+ +
+
+ +
+
+ {msg}, + }} + /> +
+
+
+ +
+
+ { + !currentIPIsInRange && + + } +
+
+); + +export default EditSectionHeader; diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_ip_not_in_range_panel.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_ip_not_in_range_panel.tsx new file mode 100644 index 0000000000..ffac24d166 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_ip_not_in_range_panel.tsx @@ -0,0 +1,49 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {AlertOutlineIcon} from '@mattermost/compass-icons/components'; + +type IPNotInRangeErrorPanelProps = { + currentUsersIP: string | null; + setShowAddModal: (show: boolean) => void; +}; + +const IPNotInRangeErrorPanel = ({ + currentUsersIP, + setShowAddModal, +}: IPNotInRangeErrorPanelProps) => ( +
+
+ +
+
+
+ +
+
+ +
setShowAddModal(true)} + > + +
+
+
+
+); + +export default IPNotInRangeErrorPanel; diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx new file mode 100644 index 0000000000..425ac25d91 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import IPFilteringEarthSvg from 'components/common/svg_images_components/ip_filtering_earth_svg'; + +type NoFiltersPanelProps = { + setShowAddModal: (show: boolean) => void; +}; + +const NoFiltersPanel = ({setShowAddModal}: NoFiltersPanelProps) => ( +
+
+ +
+
+ +
+
+ ( +
setShowAddModal(true)} + className='Button' + > + {msg} +
+ ), + }} + /> +
+
+); + +export default NoFiltersPanel; diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_sections.scss b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_sections.scss new file mode 100644 index 0000000000..92b9da5c16 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_sections.scss @@ -0,0 +1,298 @@ +.EditSection { + width: 920px; + height: auto; + max-height: 100%; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + background: var(--center-channel-bg); + border-radius: 4px; + box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08); + + .TableSectionContent { + display: inline-flex; + width: 100%; + flex-direction: column; + padding: 20px; + + .Table { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + justify-content: flex-start; + + .HeaderRow { + display: flex; + align-items: center; + align-self: stretch; + padding: 10px 12px; + border-bottom: 1px solid var(--center-channel-color-16, rgba(63, 67, 80, 0.16)); + background-color: #f5f5f5; + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 600; + gap: 10px; + line-height: 20px; + + .FilterName, + .IpAddressRange { + color: #3f4350; + font-family: 'Open Sans'; + font-size: 14px; + line-height: 20px; + word-wrap: break-word; + } + + .FilterName { + width: 291px; + font-weight: 600; + } + + .IpAddressRange { + flex: 1 1 0; + } + } + + .Row { + display: inline-flex; + height: 40px; + align-items: center; + align-self: stretch; + justify-content: flex-start; + padding: 10px 12px; + border-bottom: 1px solid var(--center-channel-color-8, rgba(63, 67, 80, 0.08)); + background: #fff; + gap: 10px; + + .FilterName, + .IpAddressRange { + color: #3f4350; + font-family: 'Open Sans'; + font-size: 14px; + line-height: 20px; + word-wrap: break-word; + } + + .FilterName { + width: 291px; + } + + .IpAddressRange { + flex: 1 1 0; + } + + .Actions { + // height: 32px; + display: flex; + align-items: center; + justify-content: center; + + >div { + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + border-radius: 4px; + + &:hover { + background: var(--center-channel-color-8, rgba(63, 67, 80, 0.08)); + } + + &.edit { + color: var(--center-channel-color-72, rgba(63, 67, 80, 0.72)); + } + + &.delete { + color: var(--error-text, rgba(210, 75, 78, 1)); + } + } + } + } + } + } + + .NoFilters { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 20px; + + .Title { + color: var(--center-channel-color, #3f4350); + font-family: Metropolis; + font-size: 20px; + font-style: normal; + font-weight: 600; + line-height: 28px; + text-align: center; + } + + .Subtitle { + max-width: 320px; + margin-top: 8px; + color: var(--center-channel-color-72, rgba(63, 67, 80, 0.72)); + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + text-align: center; + + .Button { + display: inline; + height: 20px; + padding: 0; + background: none; + color: var(--link-color, #1c58d9); + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + text-align: center; + + &:hover { + cursor: pointer; + } + } + } + } + + .AllowedIPAddressesSection { + display: inline-flex; + width: 100%; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + + .SectionHeaderContent { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + justify-content: flex-start; + padding: 24px 32px; + border-bottom: 1px solid rgba(63, 67, 80, 0.12); + gap: 24px; + + .HeaderContent { + display: inline-flex; + align-items: flex-start; + align-self: stretch; + justify-content: flex-start; + gap: 32px; + + .TitleSubtitle { + display: inline-flex; + flex: 1 1 0; + flex-direction: column; + align-items: flex-start; + justify-content: center; + + .Title { + color: #3f4350; + font-family: Metropolis; + font-size: 16px; + font-weight: 600; + line-height: 24px; + word-wrap: break-word; + } + + .Subtitle { + align-self: stretch; + color: #3f4350; + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 400; + line-height: 20px; + word-wrap: break-word; + } + } + + .AddIPFilterButton { + display: inline-flex; + flex-direction: column; + align-items: flex-end; + justify-content: flex-start; + border: none; + background: none; + gap: 16px; + + .Button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 10px 16px; + border: none; + background: #1c58d9; + border-radius: 4px; + color: #fff; + gap: 10px; + } + } + } + + .NotInRangeErrorPanel { + display: inline-flex; + width: 856px; + justify-content: flex-start; + padding: 16px; + border: 1px solid var(--error-text-16, rgba(210, 75, 78, 0.16)); + background: var(--error-text-8, rgba(210, 75, 78, 0.08)); + border-radius: 4px; + gap: 12px; + + .Icon { + color: var(--error-text, #d24b4e); + } + + .Content { + display: inline-flex; + flex: 1 1 0; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + padding-right: 24px; + gap: 8px; + + .Title { + align-self: stretch; + color: #3f4350; + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 600; + line-height: 20px; + word-wrap: break-word; + } + + .Body { + align-self: stretch; + color: #3f4350; + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 400; + line-height: 20px; + word-wrap: break-word; + } + + .Button { + display: inline; + height: 16px; + padding: 0; + padding-left: 2px; + background: none; + color: var(--button-bg, rgba(28, 88, 217, 1)); + font-size: 14px; + font-weight: 600; + line-height: 20px; + + &:hover { + cursor: pointer; + } + } + } + } + } + } +} diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/index.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/index.tsx new file mode 100644 index 0000000000..7b53b2f676 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/index.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {useIntl} from 'react-intl'; + +import type {AllowedIPRange} from '@mattermost/types/config'; + +import EditTableRow from './edit_section_edit_table_row'; +import EditSectionHeader from './edit_section_header'; +import NoFiltersPanel from './edit_section_no_filters_panel'; + +import './edit_sections.scss'; + +type EditSectionProps = { + ipFilters: AllowedIPRange[] | null; + currentUsersIP: string | null; + currentIPIsInRange: boolean; + setShowAddModal: (show: boolean) => void; + setEditFilter: (filter: AllowedIPRange) => void; + handleConfirmDeleteFilter: (filter: AllowedIPRange) => void; +}; + +const EditSection = ({ + ipFilters, + currentUsersIP, + setShowAddModal, + setEditFilter, + handleConfirmDeleteFilter, + currentIPIsInRange, +}: EditSectionProps) => { + const {formatMessage} = useIntl(); + const [hoveredRow, setHoveredRow] = useState(null); + return ( +
+ + {Boolean(ipFilters?.length) && ( +
+
+
+
+ {formatMessage({ + id: 'admin.ip_filtering.filter_name', + defaultMessage: 'Filter Name', + })} +
+
+ {formatMessage({ + id: 'admin.ip_filtering.ip_address_range', + defaultMessage: 'IP Address Range', + })} +
+
+ {ipFilters?.map((allowedIPRange, index) => ( + setHoveredRow(index)} + handleRowMouseLeave={() => setHoveredRow(null)} + setEditFilter={setEditFilter} + handleConfirmDeleteFilter={handleConfirmDeleteFilter} + hoveredRow={hoveredRow} + /> + ))} +
+
+ )} + {ipFilters?.length === 0 && } +
+ ); +}; + +export default EditSection; diff --git a/webapp/channels/src/components/admin_console/ip_filtering/enable_section.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/enable_section.test.tsx new file mode 100644 index 0000000000..8c68e0e16f --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/enable_section.test.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, fireEvent} from '@testing-library/react'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import EnableSectionContent from './enable_section'; +jest.mock('components/external_link', () => { + return jest.fn().mockImplementation(({children, ...props}) => { + return {children}; + }); +}); + +describe('EnableSectionContent', () => { + const filterToggle = true; + const setFilterToggle = jest.fn(); + + const baseProps = { + filterToggle, + setFilterToggle, + }; + + test('renders the component', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Enable IP Filtering')).toBeInTheDocument(); + expect(screen.getByText('Limit access to your workspace by IP address.')).toBeInTheDocument(); + expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument(); + expect(screen.getByRole('button', {pressed: true})).toBeInTheDocument(); + }); + + test('clicking the toggle calls setFilterToggle', () => { + renderWithContext( + , + ); + + fireEvent.click(screen.getByTestId('filterToggle-button')); + + expect(setFilterToggle).toHaveBeenCalledTimes(1); + expect(setFilterToggle).toHaveBeenCalledWith(false); + }); + + test('renders the component, with toggle not pressed if filterToggle is false', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Enable IP Filtering')).toBeInTheDocument(); + expect(screen.getByText('Limit access to your workspace by IP address.')).toBeInTheDocument(); + expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument(); + expect(screen.getByRole('button', {pressed: false})).toBeInTheDocument(); + }); +}); + diff --git a/webapp/channels/src/components/admin_console/ip_filtering/enable_section.tsx b/webapp/channels/src/components/admin_console/ip_filtering/enable_section.tsx new file mode 100644 index 0000000000..90d43374a6 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/enable_section.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import ExternalLink from 'components/external_link'; +import Toggle from 'components/toggle'; + +type Props = { + filterToggle: boolean; + setFilterToggle: (value: boolean) => void; +}; + +const EnableSectionContent: React.FC = ({filterToggle, setFilterToggle}) => { + const {formatMessage} = useIntl(); + + return ( +
+
+
+
+ {formatMessage({id: 'admin.ip_filtering.enable_ip_filtering', defaultMessage: 'Enable IP Filtering'})} +
+
+ Learn more in the docs'} + values={{ + learnmore: (msg) => ( + + {msg} + + ), + }} + /> +
+
+
+ setFilterToggle(!filterToggle)} + toggled={filterToggle} + toggleClassName='btn-toggle-primary' + /> +
+
+
+ ); +}; + +export default EnableSectionContent; diff --git a/webapp/channels/src/components/admin_console/ip_filtering/index.tsx b/webapp/channels/src/components/admin_console/ip_filtering/index.tsx new file mode 100644 index 0000000000..1669af592a --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/index.tsx @@ -0,0 +1,269 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import {AlertOutlineIcon} from '@mattermost/compass-icons/components'; +import type {AllowedIPRange, FetchIPResponse} from '@mattermost/types/config'; + +import {applyIPFilters, getCurrentIP, getIPFilters} from 'actions/admin_actions'; +import {closeModal, openModal} from 'actions/views/modals'; + +import AdminHeader from 'components/widgets/admin_console/admin_header'; + +import {ModalIdentifiers} from 'utils/constants'; + +import IPFilteringAddOrEditModal from './add_edit_ip_filter_modal'; +import DeleteConfirmationModal from './delete_confirmation'; +import EditSection from './edit_section'; +import EnableSectionContent from './enable_section'; +import {isIPAddressInRanges} from './ip_filtering_utils'; +import SaveConfirmationModal from './save_confirmation_modal'; + +import SaveChangesPanel from '../team_channel_settings/save_changes_panel'; + +import './ip_filtering.scss'; + +const IPFiltering = () => { + const dispatch = useDispatch(); + const {formatMessage} = useIntl(); + const [ipFilters, setIpFilters] = useState(null); + const [originalIpFilters, setOriginalIpFilters] = useState(null); + const [saveNeeded, setSaveNeeded] = useState(false); + const [currentUsersIP, setCurrentUsersIP] = useState(null); + const [saving, setSaving] = useState(false); + const [filterToggle, setFilterToggle] = useState(false); + + useEffect(() => { + getIPFilters((data: AllowedIPRange[]) => { + setIpFilters(data); + setOriginalIpFilters(data); + }); + + getCurrentIP((res: FetchIPResponse) => { + setCurrentUsersIP(res.ip); + }); + }, []); + + useEffect(() => { + if (ipFilters === null || originalIpFilters === null) { + return; + } + + // Check if the ipFilters list differs from the originalIpFilters list + const haveFiltersChanged = JSON.stringify(ipFilters) !== JSON.stringify(originalIpFilters); + setSaveNeeded(haveFiltersChanged); + }, [ipFilters, originalIpFilters]); + + const currentIPIsInRange = () => { + if (!filterToggle) { + return true; + } + if (!ipFilters?.length) { + return true; + } + return ipFilters !== null && currentUsersIP !== null && isIPAddressInRanges(currentUsersIP, ipFilters); + }; + + useEffect(() => { + if (!ipFilters?.length) { + return; + } + setFilterToggle(ipFilters?.some((filter: AllowedIPRange) => filter.enabled === true) ?? false); + }, [ipFilters]); + + useEffect(() => { + if (filterToggle === false) { + setIpFilters(ipFilters?.map((filter: AllowedIPRange): AllowedIPRange => { + return { + ...filter, + enabled: false, + }; + }) || []); + } else { + setIpFilters(ipFilters?.map((filter: AllowedIPRange): AllowedIPRange => { + return { + ...filter, + enabled: true, + }; + }) || []); + } + }, [filterToggle]); + + function handleEditFilter(filter: AllowedIPRange, existingRange?: AllowedIPRange) { + setIpFilters((prevIpFilters) => { + if (!prevIpFilters) { + return [filter]; + } + const index = prevIpFilters.findIndex((f) => f.cidr_block === existingRange?.cidr_block); + if (index === -1) { + return null; + } + const updatedFilters = [...prevIpFilters]; + updatedFilters[index] = filter; + return updatedFilters; + }); + setSaveNeeded(true); + } + + function showAddModal() { + dispatch(openModal({ + modalId: ModalIdentifiers.IP_FILTERING_ADD_EDIT_MODAL, + dialogType: IPFilteringAddOrEditModal, + dialogProps: { + currentIP: currentUsersIP!, + onSave: handleAddFilter, + }, + })); + } + + function showEditModal(editFilter: AllowedIPRange) { + dispatch(openModal({ + modalId: ModalIdentifiers.IP_FILTERING_ADD_EDIT_MODAL, + dialogType: IPFilteringAddOrEditModal, + dialogProps: { + currentIP: currentUsersIP!, + onSave: handleEditFilter, + existingRange: editFilter!, + }, + })); + } + + function showConfirmDeleteFilterModal(filter: AllowedIPRange) { + dispatch(openModal({ + modalId: ModalIdentifiers.IP_FILTERING_DELETE_CONFIRMATION_MODAL, + dialogType: DeleteConfirmationModal, + dialogProps: { + onConfirm: handleDeleteFilter, + filterToDelete: filter, + }, + })); + } + + function handleDeleteFilter(filter: AllowedIPRange) { + dispatch(closeModal(ModalIdentifiers.IP_FILTERING_DELETE_CONFIRMATION_MODAL)); + setIpFilters((prevIpFilters) => prevIpFilters?.filter((f) => f.cidr_block !== filter.cidr_block) ?? null); + setSaveNeeded(true); + } + + function handleAddFilter(filter: AllowedIPRange) { + dispatch(closeModal(ModalIdentifiers.IP_FILTERING_ADD_EDIT_MODAL)); + setIpFilters((prevIpFilters) => [...(prevIpFilters ?? []), filter]); + setSaveNeeded(true); + } + + function handleSave() { + setSaving(true); + dispatch(closeModal(ModalIdentifiers.IP_FILTERING_SAVE_CONFIRMATION_MODAL)); + + const success = (data: AllowedIPRange[]) => { + setIpFilters(data); + setSaving(false); + setSaveNeeded(false); + }; + + applyIPFilters(ipFilters ?? [], success); + } + + function handleSaveClick() { + const saveConfirmModalProps = { + onConfirm: handleSave, + } as any; + if (!ipFilters?.length && filterToggle) { + saveConfirmModalProps.title = formatMessage({id: 'admin.ip_filtering.apply_ip_filter_changes', defaultMessage: 'Apply IP Filter Changes'}); + saveConfirmModalProps.subtitle = ( + all IP addresses will have access to the workspace.'} + values={{ + strong: (content: string) => {content}, + }} + /> + ); + saveConfirmModalProps.buttonText = formatMessage({id: 'admin.ip_filtering.apply_changes', defaultMessage: 'Yes, apply changes'}); + saveConfirmModalProps.includeDisclaimer = false; + } else if ((ipFilters?.length && !filterToggle) || (!ipFilters?.length && !filterToggle)) { + saveConfirmModalProps.title = formatMessage({id: 'admin.ip_filtering.disable_ip_filtering', defaultMessage: 'Disable IP Filtering'}); + saveConfirmModalProps.subtitle = ( + All IP addresses will have access to the workspace.'} + values={{ + strong: (content: string) => {content}, + }} + /> + ); + saveConfirmModalProps.buttonText = formatMessage({id: 'admin.ip_filtering.yes_disable_ip_filtering', defaultMessage: 'Yes, disable IP Filtering'}); + saveConfirmModalProps.includeDisclaimer = false; + } else { + saveConfirmModalProps.title = formatMessage({id: 'admin.ip_filtering.apply_ip_filter_changes', defaultMessage: 'Apply IP Filter Changes'}); + saveConfirmModalProps.subtitle = ( + Users with IP addresses outside of the IP ranges provided will no longer have access to the workspace.'} + values={{ + strong: (content: string) => {content}, + }} + /> + ); + saveConfirmModalProps.buttonText = formatMessage({id: 'admin.ip_filtering.apply_changes', defaultMessage: 'Yes, apply changes'}); + saveConfirmModalProps.includeDisclaimer = true; + } + + dispatch(openModal({ + modalId: ModalIdentifiers.IP_FILTERING_SAVE_CONFIRMATION_MODAL, + dialogType: SaveConfirmationModal, + dialogProps: saveConfirmModalProps, + })); + } + + const saveBarError = () => { + if (currentIPIsInRange()) { + return undefined; + } + + return ( + <> + {formatMessage({id: 'admin.ip_filtering.error_on_page', defaultMessage: 'Your IP address is not included in your filters'})} + + ); + }; + + return ( +
+ + {formatMessage({id: 'admin.ip_filtering.ip_filtering', defaultMessage: 'IP Filtering'})} + +
+ <> + + {ipFilters !== null && currentUsersIP !== null && filterToggle && + + } + +
+ +
+ ); +}; + +export default IPFiltering; diff --git a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss new file mode 100644 index 0000000000..8f2ea0bc9e --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.scss @@ -0,0 +1,82 @@ +.IPFiltering { + height: 100%; + + .MainPanel { + display: flex; + height: 100%; + flex-direction: column; + align-items: flex-start; + padding: 20px; + gap: 20px; + + .EnableSectionContent { + display: inline-flex; + width: 920px; + height: 92px; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + padding: 24px 32px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + background-color: var(--center-channel-bg); + border-radius: 4px; + box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08); + gap: 24px; + + .TitleSubtitleContent { + display: inline-flex; + align-items: flex-start; + align-self: stretch; + justify-content: flex-start; + gap: 32px; + + .TitleSubtitle { + flex: 1 1 0; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 24px; + + .Title { + color: #3f4350; + font-family: Metropolis; + font-size: 16px; + font-weight: 600; + line-height: 24px; + word-wrap: break-word; + } + + .Subtitle { + align-self: stretch; + color: #3f4350; + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 400; + line-height: 20px; + word-wrap: break-word; + } + } + + .SwitchSelector { + width: 32px; + height: 20px; + flex-shrink: 0; + } + } + } + } + + .error-message { + display: flex; + align-items: center; + justify-content: center; + color: var(--error-text, #d24b4e); + font-size: 14px; + line-height: 20px; + + >svg { + margin-right: 7px; + margin-left: 7px; + } + } +} diff --git a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx new file mode 100644 index 0000000000..bdb31c8183 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering.test.tsx @@ -0,0 +1,204 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {render, fireEvent, waitFor, screen} from '@testing-library/react'; +import React from 'react'; +import {IntlProvider} from 'react-intl'; +import {Provider} from 'react-redux'; +import {BrowserRouter as Router} from 'react-router-dom'; + +import type {AllowedIPRange, FetchIPResponse} from '@mattermost/types/config'; + +import {Client4} from 'mattermost-redux/client'; + +import configureStore from 'store'; + +import ModalController from 'components/modal_controller'; + +import IPFiltering from './index'; + +jest.mock('mattermost-redux/client'); + +describe('IPFiltering', () => { + const ipFilters = [ + { + cidr_block: '10.0.0.0/8', + description: 'Test IP Filter', + enabled: true, + }, + ] as AllowedIPRange[]; + + const intlProviderProps = { + defaultLocale: 'en', + locale: 'en', + }; + const currentIP = '10.0.0.1'; + const applyIPFiltersMock = jest.fn(() => Promise.resolve(ipFilters)); + const getIPFiltersMock = jest.fn(() => Promise.resolve(ipFilters)); + const getCurrentIPMock = jest.fn(() => Promise.resolve({ip: currentIP} as FetchIPResponse)); + + beforeEach(() => { + Client4.applyIPFilters = applyIPFiltersMock; + Client4.getIPFilters = getIPFiltersMock; + Client4.getCurrentIP = getCurrentIPMock; + }); + + const mockedStore = configureStore({ + entities: { + users: { + currentUserId: 'current_user_id', + }, + general: { + config: {}, + license: {}, + }, + }, + views: { + admin: { + navigationBlock: { + blocked: false, + }, + }, + }, + }); + + const wrapWithIntlProviderAndStore = (component: JSX.Element) => ( + + + + + {component} + + + + ); + + test('renders the IP Filtering page', async () => { + const {getByText} = render(wrapWithIntlProviderAndStore()); + + expect(getByText('IP Filtering')).toBeInTheDocument(); + expect(getByText('Enable IP Filtering')).toBeInTheDocument(); + + await waitFor(() => { + expect(getByText('Add Filter')).toBeInTheDocument(); + expect(getByText('Test IP Filter')).toBeInTheDocument(); + expect(getByText('10.0.0.0/8')).toBeInTheDocument(); + }); + + expect(getByText('Save')).toBeInTheDocument(); + }); + + test('disables IP Filtering when the toggle is turned off', async () => { + render(wrapWithIntlProviderAndStore()); + + await waitFor(() => { + expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument(); + expect(screen.getByRole('button', {pressed: true})).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('filterToggle-button')); + + await waitFor(() => { + expect(screen.getByRole('button', {pressed: false})).toBeInTheDocument(); + }); + }); + + test('adds a new IP filter when the "Add IP Filter" button is clicked', async () => { + const {getByLabelText, getByText} = render(wrapWithIntlProviderAndStore()); + + await waitFor(() => { + expect(getByText('Add Filter')).toBeInTheDocument(); + }); + + fireEvent.click(getByText('Add Filter')); + + const descriptionInput = getByLabelText('Enter a name for this rule'); + const cidrInput = getByLabelText('Enter IP Range'); + const saveButton = screen.getByTestId('save-add-edit-button'); + + fireEvent.change(cidrInput, {target: {value: '192.168.0.0/16'}}); + fireEvent.change(descriptionInput, {target: {value: 'Test IP Filter 2'}}); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(getByText('Test IP Filter 2')).toBeInTheDocument(); + expect(getByText('192.168.0.0/16')).toBeInTheDocument(); + }); + }); + + test('edits an existing IP filter when the "Edit" button is clicked', async () => { + const {getByLabelText, getByText, queryByText} = render(wrapWithIntlProviderAndStore()); + + await waitFor(() => { + expect(getByText('Test IP Filter')).toBeInTheDocument(); + }); + + fireEvent.mouseEnter(screen.getByText('Test IP Filter')); + fireEvent.click(screen.getByRole('button', { + name: /Edit/i, + })); + + const descriptionInput = getByLabelText('Enter a name for this rule'); + const cidrInput = getByLabelText('Enter IP Range'); + const saveButton = screen.getByTestId('save-add-edit-button'); + + fireEvent.change(cidrInput, {target: {value: '192.168.0.0/16'}}); + fireEvent.change(descriptionInput, {target: {value: 'zzzzzfilter'}}); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(getByText('zzzzzfilter')).toBeInTheDocument(); + expect(getByText('192.168.0.0/16')).toBeInTheDocument(); + + // ensure that the old description is gone, because we've now changed it + expect(queryByText('Test IP Filter')).toBeNull(); + }); + }); + + test('deletes an existing IP filter when the "Delete" button is clicked', async () => { + const {getByText, queryByText} = render(wrapWithIntlProviderAndStore()); + + await waitFor(() => { + expect(getByText('Test IP Filter')).toBeInTheDocument(); + }); + + fireEvent.mouseEnter(screen.getByText('Test IP Filter')); + fireEvent.click(screen.getByRole('button', { + name: /Delete/i, + })); + + const confirmButton = getByText('Delete filter'); + + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(queryByText('Test IP Filter')).not.toBeInTheDocument(); + }); + }); + + test('saves changes when the "Save" button is clicked', async () => { + const {getByText, queryByText} = render(wrapWithIntlProviderAndStore()); + + await waitFor(() => { + expect(screen.getByTestId('filterToggle-button')).toBeInTheDocument(); + expect(screen.getByRole('button', {pressed: true})).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('filterToggle-button')); + + await waitFor(() => { + expect(screen.getByRole('button', {pressed: false})).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(queryByText('Test IP Filter')).not.toBeInTheDocument(); + }); + + fireEvent.click(getByText('Save')); + fireEvent.click(screen.getByTestId('save-confirmation-button')); + + await waitFor(() => { + expect(applyIPFiltersMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.test.ts b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.test.ts new file mode 100644 index 0000000000..7ff8fe0e98 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {AllowedIPRange} from '@mattermost/types/config'; + +import {isIPAddressInRanges, validateCIDR} from './ip_filtering_utils'; + +describe('isIPAddressInRanges', () => { + const allowedIPRanges = [ + { + cidr_block: '192.168.0.0/24', + description: 'Test Filter', + }, + { + cidr_block: '10.1.0.0/16', + description: 'Test Filter 2', + }, + { + cidr_block: '172.16.0.0/12', + description: 'Test Filter 3', + }, + { + cidr_block: '2001:db8::/32', + description: 'Test Filter 4', + }, + { + cidr_block: 'fe80::/10', + description: 'Test Filter 5', + }, + ] as AllowedIPRange[]; + + test('returns true if the IPv4 address is within an allowed IP range', () => { + expect(isIPAddressInRanges('192.168.0.1', allowedIPRanges)).toBe(true); + expect(isIPAddressInRanges('10.1.0.1', allowedIPRanges)).toBe(true); + expect(isIPAddressInRanges('172.16.0.1', allowedIPRanges)).toBe(true); + expect(isIPAddressInRanges('172.31.255.255', allowedIPRanges)).toBe(true); + }); + + test('returns false if the IPv4 address is not within an allowed IP range', () => { + expect(isIPAddressInRanges('192.168.1.1', allowedIPRanges)).toBe(false); + expect(isIPAddressInRanges('172.15.255.255', allowedIPRanges)).toBe(false); + expect(isIPAddressInRanges('172.32.0.1', allowedIPRanges)).toBe(false); + expect(isIPAddressInRanges('10.0.55.8', allowedIPRanges)).toBe(false); + }); + + test('returns true if the IPv6 address is within an allowed IP range', () => { + expect(isIPAddressInRanges('2001:db8::1', allowedIPRanges)).toBe(true); + expect(isIPAddressInRanges('fe80::1', allowedIPRanges)).toBe(true); + expect(isIPAddressInRanges('2001:db8:1234:5678::abcd', allowedIPRanges)).toBe(true); + }); + + test('returns false if the IPv6 address is not within an allowed IP range', () => { + expect(isIPAddressInRanges('3001::1234:5678:abcd:ef02', allowedIPRanges)).toBe(false); + expect(isIPAddressInRanges('ff80:db8:1234:5678::abce', allowedIPRanges)).toBe(false); + }); +}); + +describe('validateCIDR', () => { + const goodRanges = [ + { + cidr_block: '192.168.0.0/24', + description: 'Test Filter', + }, + { + cidr_block: '10.1.0.0/16', + description: 'Test Filter 2', + }, + { + cidr_block: '172.16.0.0/12', + description: 'Test Filter 3', + }, + { + cidr_block: '2001:db8::/32', + description: 'Test Filter 4', + }, + { + cidr_block: 'fe80::/10', + description: 'Test Filter 5', + }, + ] as AllowedIPRange[]; + + const badRanges = [ + { + cidr_block: 'fe80::1234:5678:abcd:ef01:/8', + }, + ]; + + test('returns true for valid CIDR blocks', () => { + for (const allowedIPRange of goodRanges) { + expect(validateCIDR(allowedIPRange.cidr_block)).toBeTruthy(); + } + }); + + test('returns false for invalid CIDR blocks', () => { + for (const allowedIPRange of badRanges) { + expect(validateCIDR(allowedIPRange.cidr_block)).not.toBeTruthy(); + } + }); +}); diff --git a/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.ts b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.ts new file mode 100644 index 0000000000..c7b4a03b2b --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/ip_filtering_utils.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import ipaddr from 'ipaddr.js'; + +import type {AllowedIPRange} from '@mattermost/types/config'; + +export function isIPAddressInRanges(ipAddress: string, allowedIPRanges: AllowedIPRange[]): boolean { + const usersAddr = ipaddr.parse(ipAddress); + + for (const allowedIPRange of allowedIPRanges) { + const cidrBlock = allowedIPRange.cidr_block; + const [addr, mask] = ipaddr.parseCIDR(cidrBlock); + + if (usersAddr.kind() !== addr.kind()) { + // We can only compare ipv4 to ipv4 and ipv6 to ipv6, cannot compare ipv4 to ipv6 + continue; + } + + if (usersAddr.match([addr, mask])) { + return true; + } + } + + return false; +} + +export function validateCIDR(cidr: string) { + try { + ipaddr.parseCIDR(cidr); + } catch (e) { + return false; + } + + return true; +} diff --git a/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.scss b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.scss new file mode 100644 index 0000000000..33052da205 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.scss @@ -0,0 +1,165 @@ +.SaveConfirmationModal { + .modal-dialog { + position: absolute; + top: 50%; + left: 50%; + width: 600px; + border: 1 px solid rgba(var(--center-channel-color-rgb), 0.08); + margin: auto; + border-radius: 12px; + transform: translate(-50%, -50%) !important; + } + + .modal-content { + border-radius: 12px; + } + + .modal-header { + .close { + &:hover { + background-color: + rgba( + var(--center-channel-color-rgb), + 0.08 + ); + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + &:active { + background-color: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + } + + top: 26px; + right: 26px; + width: 24px; + height: 24px; + border-radius: 4px; + color: rgba(var(--center-channel-color-rgb), 0.56) !important; + font-family: + 'Open Sans', + sans-serif; + font-size: 32px; + font-weight: 400; + } + + .title { + font-family: Metropolis, sans-serif; + font-size: 22px; + font-style: normal; + font-weight: 600; + line-height: 28px; + text-align: left; + } + + display: block; + min-height: 48px; + padding-top: 26px; + padding-bottom: 0; + padding-left: 32px; + border: 0; + background: var(--center-channel-bg) !important; + border-radius: 12px; + color: var(--center-channel-color); + } + + .modal-body { + overflow: hidden; + width: 100%; + flex-direction: column; + padding-top: 24px; + padding-right: 32px; + padding-bottom: 26px; + padding-left: 32px; + color: var(--center-channel-color, #3f4350); + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + + .disclaimer { + display: inline-flex; + width: 536px; + height: 120px; + align-items: flex-start; + justify-content: flex-start; + padding: 16px; + border: 1px solid rgba(87, 158, 255, 0.16); + margin-top: 32px; + background: rgba(87, 158, 255, 0.08); + border-radius: 4px; + gap: 12px; + + .Icon { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + + svg { + width: 20px; + height: 20px; + fill: var(--sidebar-text-active-border, rgba(93, 137, 234, 1)); + } + } + + .Body { + display: inline-flex; + flex: 1 1 0; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + padding-right: 24px; + gap: 8px; + + .Title { + align-self: stretch; + color: #3f4350; + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 600; + line-height: 20px; + word-wrap: break-word; + } + + .Subtitle { + color: #3f4350; + font-family: 'Open Sans'; + font-size: 14px; + font-weight: 400; + line-height: 20px; + word-wrap: break-word; + } + } + } + } + + .modal-footer { + padding: 24px 32px; + border-top: none; + border-radius: 12px; + font-family: Open Sans; + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 20px; + + .btn-cancel { + padding: 10px 20px; + border: none; + background: var(--button-bg-8, rgba(28, 88, 217, 0.08)); + border-radius: 4px; + color: var(--button-bg, #1c58d9); + } + + .btn-delete { + padding: 10px 20px; + border: none; + margin-left: 8px; + background: var(--denim-status-do-not-disturb, #d24b4e); + border-radius: 4px; + color: var(--button-color, #fff); + } + } +} diff --git a/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.test.tsx b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.test.tsx new file mode 100644 index 0000000000..4ba1c1ae96 --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.test.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent} from '@testing-library/react'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import SaveConfirmationModal from './save_confirmation_modal'; + +jest.mock('components/external_link', () => { + return jest.fn().mockImplementation(({children, ...props}) => { + return {children}; + }); +}); + +describe('SaveConfirmationModal', () => { + const onExitedMock = jest.fn(); + const onConfirmMock = jest.fn(); + const title = 'Test Title'; + const subtitle = 'Test Subtitle'; + const buttonText = 'Test Button Text'; + + const baseProps = { + onExited: onExitedMock, + onConfirm: onConfirmMock, + title, + subtitle, + buttonText, + }; + + test('renders the title and subtitle', () => { + const {getByText} = renderWithContext( + , + ); + + expect(getByText(title)).toBeInTheDocument(); + expect(getByText(subtitle)).toBeInTheDocument(); + }); + + test('renders the disclaimer if includeDisclaimer is true', () => { + const {getByText} = renderWithContext( + , + ); + + expect(getByText('Using the Customer Portal to restore access')).toBeInTheDocument(); + }); + + test('calls onClose when the cancel button is clicked', () => { + const {getByText} = renderWithContext( + , + ); + + fireEvent.click(getByText('Cancel')); + + expect(onExitedMock).toHaveBeenCalledTimes(1); + }); + + test('calls onConfirm when the confirm button is clicked', () => { + const {getByText} = renderWithContext( + , + ); + + fireEvent.click(getByText(buttonText)); + + expect(onConfirmMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.tsx b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.tsx new file mode 100644 index 0000000000..7f057cca7c --- /dev/null +++ b/webapp/channels/src/components/admin_console/ip_filtering/save_confirmation_modal.tsx @@ -0,0 +1,85 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {InformationOutlineIcon} from '@mattermost/compass-icons/components'; + +import ExternalLink from 'components/external_link'; + +import './save_confirmation_modal.scss'; + +type Props = { + onExited: () => void; + onConfirm?: () => void; + title?: string; + subtitle: JSX.Element | string; + buttonText?: string; + includeDisclaimer?: boolean; +} + +export default function SaveConfirmationModal({onExited, onConfirm, title, subtitle, includeDisclaimer, buttonText}: Props) { + const {formatMessage} = useIntl(); + return ( + + +
+ {title} +
+
+ + {subtitle} + {includeDisclaimer && +
+
+ +
+
+
{formatMessage({id: 'admin.ip_filtering.save_disclaimer_title', defaultMessage: 'Using the Customer Portal to restore access'})}
+ {/* TODO - replace "workspace owner" with owner's email address? */} +
+ Customer Portal to disable IP filtering to restore access.'} + values={{ + customerportal: (msg) => ( + + {msg} + ), + }} + /> +
+
+
+ } +
+ + + + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx index 674cb0d023..ca849fed43 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx @@ -27,17 +27,19 @@ const SaveChangesPanel = ({saveNeeded, onClick, saving, serverError, cancelLink, onClick={onClick} savingMessage={localizeMessage('admin.team_channel_settings.saving', 'Saving Config...')} /> - - - - + { + cancelLink !== '' && + + + + }
{serverError}
diff --git a/webapp/channels/src/components/common/svg_images_components/ip_filtering_earth_svg.tsx b/webapp/channels/src/components/common/svg_images_components/ip_filtering_earth_svg.tsx new file mode 100644 index 0000000000..83e0d9fbab --- /dev/null +++ b/webapp/channels/src/components/common/svg_images_components/ip_filtering_earth_svg.tsx @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +type SvgProps = { + width: number; + height: number; +} + +const IPFilteringEarthSvg = ({width, height}: SvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + +); + +export default IPFilteringEarthSvg; diff --git a/webapp/channels/src/components/toggle.tsx b/webapp/channels/src/components/toggle.tsx index 90e1858a2c..88d0e52612 100644 --- a/webapp/channels/src/components/toggle.tsx +++ b/webapp/channels/src/components/toggle.tsx @@ -12,7 +12,7 @@ type Props = { offText?: React.ReactNode; id?: string; overrideTestId?: boolean; - size?: 'btn-lg' | 'btn-sm'; + size?: 'btn-lg' | 'btn-md' |'btn-sm'; toggleClassName?: string; } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 58f3a6bb26..a0756bdadf 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1179,6 +1179,45 @@ "admin.integrations.gif": "GIF (Beta)", "admin.integrations.integrationManagement": "Integration Management", "admin.integrations.integrationManagement.title": "Integration Management", + "admin.ip_filtering.add_filter": "Add a filter", + "admin.ip_filtering.add_ip_filter": "Add IP Filter", + "admin.ip_filtering.add_your_ip": "Add your IP address", + "admin.ip_filtering.allow_following_range": "Allow the following range of IP Addresses", + "admin.ip_filtering.allowed_ip_addresses": "Allowed IP Addresses", + "admin.ip_filtering.any_ip_can_access_add_filter": "Any IP can access your workspace. To limit access to selected IP Addresses, Add a filter.", + "admin.ip_filtering.apply_changes": "Yes, apply changes", + "admin.ip_filtering.apply_ip_filter_changes": "Apply IP Filter Changes", + "admin.ip_filtering.apply_ip_filter_changes_are_you_sure": "Are you sure you want to apply these IP Filter changes? Users with IP addresses outside of the IP ranges provided will no longer have access to the workspace.", + "admin.ip_filtering.cancel": "Cancel", + "admin.ip_filtering.delete": "Delete", + "admin.ip_filtering.delete_confirmation_body": "Are you sure you want to delete IP filter {filter}? Users with IP addresses outside of this range won't be able to access the workspace when IP Filtering is enabled", + "admin.ip_filtering.delete_confirmation_title": "Delete IP Filter", + "admin.ip_filtering.delete_filter": "Delete filter", + "admin.ip_filtering.disable_ip_filtering": "Disable IP Filtering", + "admin.ip_filtering.edit": "Edit", + "admin.ip_filtering.edit_ip_filter": "Edit IP Filter", + "admin.ip_filtering.edit_section_description_line_1": "Create rules to allow access to the workspace for specified IP addresses only.", + "admin.ip_filtering.edit_section_description_line_2": "NOTE: If no rules are added, all IP addresses will be allowed.", + "admin.ip_filtering.enable_ip_filtering": "Enable IP Filtering", + "admin.ip_filtering.enable_ip_filtering_description": "Limit access to your workspace by IP address. Learn more in the docs", + "admin.ip_filtering.error_on_page": "Your IP address is not included in your filters", + "admin.ip_filtering.filter_name": "Filter Name", + "admin.ip_filtering.include_your_ip": "Include your IP address in at least one of the rules below to continue.", + "admin.ip_filtering.ip_address_range": "IP Address Range", + "admin.ip_filtering.ip_filtering": "IP Filtering", + "admin.ip_filtering.more_info": "Enter ranges in CIDR format (e.g. 192.168.0.1/8). More info", + "admin.ip_filtering.name": "Name", + "admin.ip_filtering.no_filters": "No IP filtering rules added", + "admin.ip_filtering.no_filters_added": "Are you sure you want to apply these IP filter changes? There are currently no filters added, so all IP addresses will have access to the workspace.", + "admin.ip_filtering.rule_name_placeholder": "Enter a name for this rule", + "admin.ip_filtering.save": "Save", + "admin.ip_filtering.save_disclaimer_subtitle": "If you happen to block yourself with these settings, your workspace owner can log in to the Customer Portal to disable IP filtering to restore access.", + "admin.ip_filtering.save_disclaimer_title": "Using the Customer Portal to restore access", + "admin.ip_filtering.turn_off_ip_filtering": "Are you sure you want to turn off IP Filtering? All IP addresses will have access to the workspace.", + "admin.ip_filtering.update_filter": "Update filter", + "admin.ip_filtering.yes_disable_ip_filtering": "Yes, disable IP Filtering", + "admin.ip_filtering.your_current_ip_is": "Your current IP address is {ip}", + "admin.ip_filtering.your_current_ip_is_not_in_allowed_rules": "Your IP address {ip} is not included in your allowed IP address rules.", "admin.jobTable.cancelButton": "Cancel", "admin.jobTable.downloadLink": "Download", "admin.jobTable.headerExtraInfo": "Details", @@ -2273,6 +2312,7 @@ "admin.sidebar.highAvailability": "High Availability", "admin.sidebar.imageProxy": "Image Proxy", "admin.sidebar.integrations": "Integrations", + "admin.sidebar.ip_filtering": "IP Filtering", "admin.sidebar.ldap": "AD/LDAP", "admin.sidebar.license": "Edition and License", "admin.sidebar.localization": "Localization", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts index 226b92873a..2c874d5373 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts @@ -8,6 +8,7 @@ import type { ChannelSearchOpts, } from '@mattermost/types/channels'; import type {Compliance} from '@mattermost/types/compliance'; +import type {AllowedIPRange} from '@mattermost/types/config'; import type { CreateDataRetentionCustomPolicy, } from '@mattermost/types/data_retention'; @@ -845,3 +846,24 @@ export function getAppliedSchemaMigrations(): ActionFunc { clientFunc: Client4.getAppliedSchemaMigrations, }); } + +export function getIPFilters() { + return bindClientFunc({ + clientFunc: Client4.getIPFilters, + params: [], + }); +} + +export function getCurrentIP() { + return bindClientFunc({ + clientFunc: Client4.getCurrentIP, + params: [], + }); +} + +export function applyIPFilters(ipFilters: AllowedIPRange[]) { + return bindClientFunc({ + clientFunc: Client4.applyIPFilters, + params: [ipFilters], + }); +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts index 1054aadd96..e77752f6c5 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions.ts @@ -152,6 +152,8 @@ const values = { SYSCONSOLE_WRITE_SITE_PUBLIC_LINKS: 'sysconsole_write_site_public_links', SYSCONSOLE_READ_SITE_NOTICES: 'sysconsole_read_site_notices', SYSCONSOLE_WRITE_SITE_NOTICES: 'sysconsole_write_site_notices', + SYSCONSOLE_READ_SITE_IP_FILTERS: 'sysconsole_read_site_ip_filters', + SYSCONSOLE_WRITE_SITE_IP_FILTERS: 'sysconsole_write_site_ip_filters', SYSCONSOLE_READ_ENVIRONMENT_WEB_SERVER: 'sysconsole_read_environment_web_server', SYSCONSOLE_WRITE_ENVIRONMENT_WEB_SERVER: 'sysconsole_write_environment_web_server', SYSCONSOLE_READ_ENVIRONMENT_DATABASE: 'sysconsole_read_environment_database', diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions_sysconsole.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions_sysconsole.ts index 01b8bae001..af24bf1ac2 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/permissions_sysconsole.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/permissions_sysconsole.ts @@ -56,6 +56,7 @@ export const RESOURCE_KEYS = { FILE_SHARING_AND_DOWNLOADS: 'site.file_sharing_and_downloads', PUBLIC_LINKS: 'site.public_links', NOTICES: 'site.notices', + IP_FILTERING: 'site.ip_filters', }, EXPERIMENTAL: { FEATURES: 'experimental.features', @@ -101,6 +102,7 @@ export const ResourceToSysConsolePermissionsTable: Record = { [RESOURCE_KEYS.SITE.FILE_SHARING_AND_DOWNLOADS]: [Permissions.SYSCONSOLE_READ_SITE_FILE_SHARING_AND_DOWNLOADS, Permissions.SYSCONSOLE_WRITE_SITE_FILE_SHARING_AND_DOWNLOADS], [RESOURCE_KEYS.SITE.PUBLIC_LINKS]: [Permissions.SYSCONSOLE_READ_SITE_PUBLIC_LINKS, Permissions.SYSCONSOLE_WRITE_SITE_PUBLIC_LINKS], [RESOURCE_KEYS.SITE.NOTICES]: [Permissions.SYSCONSOLE_READ_SITE_NOTICES, Permissions.SYSCONSOLE_WRITE_SITE_NOTICES], + [RESOURCE_KEYS.SITE.IP_FILTERING]: [Permissions.SYSCONSOLE_READ_SITE_IP_FILTERS, Permissions.SYSCONSOLE_WRITE_SITE_IP_FILTERS], [RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER]: [Permissions.SYSCONSOLE_READ_ENVIRONMENT_WEB_SERVER, Permissions.SYSCONSOLE_WRITE_ENVIRONMENT_WEB_SERVER], [RESOURCE_KEYS.ENVIRONMENT.DATABASE]: [Permissions.SYSCONSOLE_READ_ENVIRONMENT_DATABASE, Permissions.SYSCONSOLE_WRITE_ENVIRONMENT_DATABASE], [RESOURCE_KEYS.ENVIRONMENT.ELASTICSEARCH]: [Permissions.SYSCONSOLE_READ_ENVIRONMENT_ELASTICSEARCH, Permissions.SYSCONSOLE_WRITE_ENVIRONMENT_ELASTICSEARCH], diff --git a/webapp/channels/src/sass/components/_toggle.scss b/webapp/channels/src/sass/components/_toggle.scss index 68acedb814..e75f74aadb 100644 --- a/webapp/channels/src/sass/components/_toggle.scss +++ b/webapp/channels/src/sass/components/_toggle.scss @@ -84,6 +84,10 @@ $toggle-default-font-size: 0.75rem; @include toggle-mixin($size: 2.8rem, $font-size: 1rem, $margin: 5rem); } + &.btn-md { + @include toggle-mixin($size: 2.2rem, $font-size: 0.8rem, $margin: 3rem); + } + &.btn-sm { @include toggle-mixin($font-size: 0.55rem, $margin: 0.5rem); } @@ -123,6 +127,10 @@ $toggle-default-font-size: 0.75rem; @include toggle-mixin($size: 2.8rem, $font-size: 1rem, $margin: 5rem); } + &.btn-md { + @include toggle-mixin($size: 2.2rem, $font-size: 0.8rem, $margin: 3rem); + } + &.btn-sm { @include toggle-mixin($font-size: 0.55rem, $margin: 0.5rem); } diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 3586fabbcc..bce023ac39 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -446,6 +446,9 @@ export const ModalIdentifiers = { START_TRIAL_FORM_MODAL: 'start_trial_form_modal', START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result', CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel', + IP_FILTERING_ADD_EDIT_MODAL: 'ip_filtering_add_edit_modal', + IP_FILTERING_DELETE_CONFIRMATION_MODAL: 'ip_filtering_delete_confirmation_modal', + IP_FILTERING_SAVE_CONFIRMATION_MODAL: 'ip_filtering_save_confirmation_modal', }; export const UserStatuses = { diff --git a/webapp/package-lock.json b/webapp/package-lock.json index f22c25350f..c3b318484a 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -92,6 +92,7 @@ "hoist-non-react-statics": "3.3.2", "html-to-react": "1.6.0", "inobounce": "0.2.1", + "ipaddr.js": "2.1.0", "katex": "0.16.3", "key-mirror": "1.0.1", "localforage": "1.10.0", @@ -17770,7 +17771,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", - "dev": true, "engines": { "node": ">= 10" } @@ -39659,8 +39659,7 @@ "ipaddr.js": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", - "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", - "dev": true + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==" }, "is-alphabetical": { "version": "1.0.4", @@ -41857,6 +41856,7 @@ "identity-obj-proxy": "3.0.0", "image-webpack-loader": "8.1.0", "inobounce": "0.2.1", + "ipaddr.js": "2.1.0", "isomorphic-fetch": "3.0.0", "jest": "29.7.0", "jest-canvas-mock": "2.5.0", diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 0e40561b1d..2ff355009a 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -61,6 +61,9 @@ import { AdminConfig, EnvironmentConfig, RequestLicenseBody, + AllowedIPRanges, + AllowedIPRange, + FetchIPResponse, } from '@mattermost/types/config'; import {CustomEmoji} from '@mattermost/types/emojis'; import {ServerError} from '@mattermost/types/errors'; @@ -4161,6 +4164,27 @@ export default class Client4 { ); }; + getIPFilters = () => { + return this.doFetch( + `${this.getBaseRoute()}/ip_filtering`, + {method: 'get'}, + ) + } + + getCurrentIP = () => { + return this.doFetch( + `${this.getBaseRoute()}/ip_filtering/my_ip`, + {method: 'get'}, + ) + } + + applyIPFilters = (filters: AllowedIPRanges) => { + return this.doFetch( + `${this.getBaseRoute()}/ip_filtering`, + {method: 'post', body: JSON.stringify(filters)}, + ) + } + submitTrueUpReview = () => { return this.doFetch( `${this.getBaseRoute()}/license/review`, diff --git a/webapp/platform/types/src/admin.ts b/webapp/platform/types/src/admin.ts index caf19c5566..c93ecbc233 100644 --- a/webapp/platform/types/src/admin.ts +++ b/webapp/platform/types/src/admin.ts @@ -3,7 +3,7 @@ import {Audit} from './audits'; import {Compliance} from './compliance'; -import {AdminConfig, ClientLicense, EnvironmentConfig} from './config'; +import {AdminConfig, AllowedIPRange, ClientLicense, EnvironmentConfig} from './config'; import {DataRetentionCustomPolicies} from './data_retention'; import {MixedUnlinkedGroupRedux} from './groups'; import {PluginRedux, PluginStatusRedux} from './plugins'; diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 8157b88c14..1924e5c5fc 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -977,3 +977,17 @@ export enum ServiceEnvironment { TEST = 'test', DEV = 'dev', } + + +export type AllowedIPRange = { + cidr_block: string; + description: string; + enabled: boolean; + owner_id: string; +} + +export type AllowedIPRanges = AllowedIPRange[]; + +export type FetchIPResponse = { + ip: string; +}