From 3cd9e4d99a568464e99ef2f11161f6ce05c67f4f Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Thu, 23 May 2024 14:38:59 -0400 Subject: [PATCH 1/5] fix process.env frontend issue (#48) --- webapp/webpack.config.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/webapp/webpack.config.js b/webapp/webpack.config.js index a785c8f..a3d9032 100644 --- a/webapp/webpack.config.js +++ b/webapp/webpack.config.js @@ -2,12 +2,18 @@ const exec = require('child_process').exec; const path = require('path'); +const webpack = require('webpack'); + const PLUGIN_ID = require('../plugin.json').id; const NPM_TARGET = process.env.npm_lifecycle_event; //eslint-disable-line no-process-env const isDev = NPM_TARGET === 'debug' || NPM_TARGET === 'debug:watch'; -const plugins = []; +const plugins = [ + new webpack.ProvidePlugin({ + process: 'process/browser', + }), +]; if (NPM_TARGET === 'build:watch' || NPM_TARGET === 'debug:watch') { plugins.push({ apply: (compiler) => { From 3a927917d17844b8101f34bbd7d73fe1048ab000 Mon Sep 17 00:00:00 2001 From: Felipe Martin <812088+fmartingr@users.noreply.github.com> Date: Thu, 23 May 2024 20:40:46 +0200 Subject: [PATCH 2/5] fix: check for enterprise licence on plugin activation (#49) Fixes #5 --- server/plugin.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/plugin.go b/server/plugin.go index 63178d4..ccb32a1 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -56,6 +56,14 @@ type Plugin struct { } func (p *Plugin) OnActivate() error { + // Check for an enterprise license or a development environment + config := p.API.GetConfig() + license := p.API.GetLicense() + + if !pluginapi.IsEnterpriseLicensedOrDevelopment(config, license) { + return fmt.Errorf("this plugin requires an Enterprise license") + } + // Create plugin API client p.Client = pluginapi.NewClient(p.API, p.Driver) p.Client.Log.Debug("MM LH Plugin: OnActivate called") From 7f7b058e2f9991e34e85054437f55edf010c6ab5 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Fri, 24 May 2024 11:07:35 -0400 Subject: [PATCH 3/5] Add API endpoint to run legal hold job on demand (#43) * add api endpoint to run legal hold job on demand * fix lint * PR feedback * call job in goroutine to avoid blocking ServeHTTP hook * reorder defer blocks for cleaning up legal hold job * call context cancel function in defer * move processAllLegalHolds body back into run() method --------- Co-authored-by: wiggin77 --- server/api.go | 23 ++++++++++++++++++----- server/jobs/legal_hold_job.go | 34 ++++++++++++++++++++++------------ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/server/api.go b/server/api.go index 6b361c7..f8647e2 100644 --- a/server/api.go +++ b/server/api.go @@ -37,11 +37,15 @@ func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Req router := mux.NewRouter() - router.HandleFunc("/api/v1/legalhold/list", p.listLegalHolds) - router.HandleFunc("/api/v1/legalhold/create", p.createLegalHold) - router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/release", p.releaseLegalHold) - router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/update", p.updateLegalHold) - router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold) + // Routes called by the plugin's webapp + router.HandleFunc("/api/v1/legalhold/list", p.listLegalHolds).Methods(http.MethodGet) + router.HandleFunc("/api/v1/legalhold/create", p.createLegalHold).Methods(http.MethodPost) + router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/release", p.releaseLegalHold).Methods(http.MethodPost) + router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/update", p.updateLegalHold).Methods(http.MethodPost) + router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold).Methods(http.MethodGet) + + // Other routes + router.HandleFunc("/api/v1/legalhold/run", p.runJobFromAPI).Methods(http.MethodPost) p.router = router p.router.ServeHTTP(w, r) @@ -289,6 +293,15 @@ func (p *Plugin) downloadLegalHold(w http.ResponseWriter, r *http.Request) { } } +func (p *Plugin) runJobFromAPI(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte("Processing all Legal Holds. Please check the MM server logs for more details.")) + if err != nil { + p.API.LogError("failed to write http response", err.Error()) + } + + go p.legalHoldJob.RunFromAPI() +} + func RequireLegalHoldID(r *http.Request) (string, error) { props := mux.Vars(r) diff --git a/server/jobs/legal_hold_job.go b/server/jobs/legal_hold_job.go index 9aabfc4..a9e88f6 100644 --- a/server/jobs/legal_hold_job.go +++ b/server/jobs/legal_hold_job.go @@ -148,7 +148,20 @@ func (j *LegalHoldJob) nextWaitInterval(now time.Time, metaData cluster.JobMetad return delta } +func (j *LegalHoldJob) RunFromAPI() { + j.run() +} + func (j *LegalHoldJob) run() { + j.mux.Lock() + oldRunner := j.runner + j.mux.Unlock() + + if oldRunner != nil { + j.client.Log.Error("Multiple Legal Hold jobs scheduled concurrently; there can be only one") + return + } + j.client.Log.Info("Running Legal Hold Job") exitSignal := make(chan struct{}) ctx, canceller := context.WithCancel(context.Background()) @@ -158,25 +171,22 @@ func (j *LegalHoldJob) run() { exitSignal: exitSignal, } - var oldRunner *runInstance - var settings *LegalHoldJobSettings - j.mux.Lock() - oldRunner = j.runner - j.runner = runner - settings = j.settings.Clone() - j.mux.Unlock() - defer func() { + canceller() close(exitSignal) + j.mux.Lock() j.runner = nil j.mux.Unlock() }() - if oldRunner != nil { - j.client.Log.Error("Multiple Legal Hold jobs scheduled concurrently; there can be only one") - return - } + var settings *LegalHoldJobSettings + j.mux.Lock() + j.runner = runner + settings = j.settings.Clone() + j.mux.Unlock() + + j.client.Log.Info("Processing all Legal Holds") // Retrieve the legal holds from the store. legalHolds, err := j.kvstore.GetAllLegalHolds() From 93a41b534066c6fd0cc319686603e2bad4500aaf Mon Sep 17 00:00:00 2001 From: Felipe Martin <812088+fmartingr@users.noreply.github.com> Date: Tue, 28 May 2024 07:56:02 +0200 Subject: [PATCH 4/5] docs: note on timeout issues for large downloads (#50) --- README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a9f42f8..7fd0d08 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Although a valid Mattermost Enterprise Edition License is required if using this If you're running an Enterprise Edition of Mattermost and don't already have a valid license, you can obtain a trial license from **System Console > Edition and License**. If you're running the Team Edition of Mattermost, including when you run the server directly from source, you may instead configure your server to enable both testing (`ServiceSettings.EnableTesting`) and developer mode (`ServiceSettings.EnableDeveloper`). These settings are not recommended in production environments. - ## How To Install Download the latest released version and upload to your Mattermost installation on the plugins page @@ -23,8 +22,8 @@ of the System Console in the usual way. Once the plugin is installed, a new "Legal Hold" section will appear in the System Console UI in the Plugins section. There are two main settings: -* **Enable Plugin**: controls whether the plugin is enabled. It must be enabled to use it. -* **Time of Day**: this setting controls at what time the delay collection of Legal Hold data +- **Enable Plugin**: controls whether the plugin is enabled. It must be enabled to use it. +- **Time of Day**: this setting controls at what time the delay collection of Legal Hold data should occur. We recommend choosing a quiet time of day to minimise impact on your users. Make sure to specify the time in the format shown in the example. @@ -40,8 +39,8 @@ present in your Mattermost server on the first run of the job will be saved (i.e already been purged by a data retention policy at the time of the first run will not be included in the legal hold). Once data is held by the Legal Hold, it will not be affected by Data Retention policy. However, newly created Legal Holds will not be able to access data that was already purged -by Data Retention policy at the time of their first run *even if the data is held in an existing -legal hold*. +by Data Retention policy at the time of their first run _even if the data is held in an existing +legal hold_. You can edit the name, end data and users in a Legal Hold. Adding new users to a legal hold will only include their data from the next run of the hold. Similarly, removing a user from the hold will @@ -58,3 +57,9 @@ permanently purged from the storage area, and cannot be recovered. See the `processor` subdirectory for how to turn the downloaded zip file into a human readable HTML export that you can view and search in a web browser. + +## A note on downlading large legal holds + +For large legal holds, the download process can take more time than the HTTP request timeout. If you are experiencing timeouts, you can increase the timeout under **System Console** > **Web server** > **Write timeout** or in your `config.json` file. This is a global setting for the entire server. + +Keep in mind that the same applies for reverse proxies, which may have their own timeout settings. If you are using a reverse proxy, you may need to adjust the timeout settings there as well. From 5fcd8685e38c05b4eaa3f5f0d122b4d3d498d3fa Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Thu, 30 May 2024 19:09:25 -0400 Subject: [PATCH 5/5] Support using a different s3 bucket through plugin setting (#46) * add api endpoint to run legal hold job on demand * fix lint * PR feedback * call job in goroutine to avoid blocking ServeHTTP hook * reorder defer blocks for cleaning up legal hold job * call context cancel function in defer * move processAllLegalHolds body back into run() method * support using a different s3 bucket through plugin setting * move code around * update readme * initial s3 bucket form implementation * test connection works * show success/fail connection messages. check connection on plugin startup * update readme * disable inputs instead of hiding * reorder settings in the admin console * show aws secret key as asterisks * remove comment from code --------- Co-authored-by: wiggin77 --- README.md | 5 +- plugin.json | 5 + server/api.go | 43 ++++ server/api_test.go | 61 ++++- server/config/configuration.go | 10 +- server/plugin.go | 16 +- webapp/src/client.ts | 9 +- .../admin_console_settings/base_setting.tsx | 32 +++ .../boolean_setting.tsx | 41 +++ .../secret_text_setting.tsx | 51 ++++ .../admin_console_settings/status_message.tsx | 38 +++ .../admin_console_settings/text_setting.tsx | 31 +++ .../components/amazon_s3_bucket_settings.tsx | 233 ++++++++++++++++++ webapp/src/components/legal_holds_setting.tsx | 1 + .../mattermost-webapp/save_button.tsx | 7 +- webapp/src/index.tsx | 2 + 16 files changed, 575 insertions(+), 10 deletions(-) create mode 100644 webapp/src/components/admin_console_settings/base_setting.tsx create mode 100644 webapp/src/components/admin_console_settings/boolean_setting.tsx create mode 100644 webapp/src/components/admin_console_settings/secret_text_setting.tsx create mode 100644 webapp/src/components/admin_console_settings/status_message.tsx create mode 100644 webapp/src/components/admin_console_settings/text_setting.tsx create mode 100644 webapp/src/components/amazon_s3_bucket_settings.tsx diff --git a/README.md b/README.md index 7fd0d08..14eed11 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,9 @@ of the System Console in the usual way. Once the plugin is installed, a new "Legal Hold" section will appear in the System Console UI in the Plugins section. There are two main settings: -- **Enable Plugin**: controls whether the plugin is enabled. It must be enabled to use it. -- **Time of Day**: this setting controls at what time the delay collection of Legal Hold data +* **Enable Plugin**: controls whether the plugin is enabled. It must be enabled to use it. +* **Amazon S3 Bucket Settings**: optionally use a separate S3 Bucket than the one configured for your Mattermost server. +* **Time of Day**: this setting controls at what time the delay collection of Legal Hold data should occur. We recommend choosing a quiet time of day to minimise impact on your users. Make sure to specify the time in the format shown in the example. diff --git a/plugin.json b/plugin.json index 832c2fb..617bb35 100644 --- a/plugin.json +++ b/plugin.json @@ -35,6 +35,11 @@ "key": "LegalHoldsSettings", "display_name": "Legal Holds:", "type": "custom" + }, + { + "key": "AmazonS3BucketSettings", + "display_name": "S3 Bucket:", + "type": "custom" } ] } diff --git a/server/api.go b/server/api.go index f8647e2..0405c23 100644 --- a/server/api.go +++ b/server/api.go @@ -12,6 +12,7 @@ import ( "github.com/gorilla/mux" mattermostModel "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" + "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/pkg/errors" "github.com/mattermost/mattermost-plugin-legal-hold/server/model" @@ -43,6 +44,7 @@ func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Req router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/release", p.releaseLegalHold).Methods(http.MethodPost) router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/update", p.updateLegalHold).Methods(http.MethodPost) router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold).Methods(http.MethodGet) + router.HandleFunc("/api/v1/test_amazon_s3_connection", p.testAmazonS3Connection).Methods(http.MethodPost) // Other routes router.HandleFunc("/api/v1/legalhold/run", p.runJobFromAPI).Methods(http.MethodPost) @@ -302,6 +304,47 @@ func (p *Plugin) runJobFromAPI(w http.ResponseWriter, _ *http.Request) { go p.legalHoldJob.RunFromAPI() } +// testAmazonS3Connection tests the plugin's custom Amazon S3 connection +func (p *Plugin) testAmazonS3Connection(w http.ResponseWriter, _ *http.Request) { + type messageResponse struct { + Message string `json:"message"` + } + + var err error + + conf := p.getConfiguration() + if !conf.AmazonS3BucketSettings.Enable { + http.Error(w, "Amazon S3 bucket settings are not enabled", http.StatusBadRequest) + return + } + + filesBackendSettings := FixedFileSettingsToFileBackendSettings(conf.AmazonS3BucketSettings.Settings, false, true) + filesBackend, err := filestore.NewFileBackend(filesBackendSettings) + if err != nil { + err = errors.Wrap(err, "unable to initialize the file store") + http.Error(w, err.Error(), http.StatusInternalServerError) + p.Client.Log.Error(err.Error()) + return + } + + if err = filesBackend.TestConnection(); err != nil { + err = errors.Wrap(err, "failed to connect to Amazon S3 bucket") + http.Error(w, err.Error(), http.StatusInternalServerError) + p.Client.Log.Error(err.Error()) + return + } + + response := messageResponse{ + Message: "Successfully connected to Amazon S3 bucket", + } + + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(response) + if err != nil { + p.Client.Log.Error("failed to write http response", err.Error()) + } +} + func RequireLegalHoldID(r *http.Request) (string, error) { props := mux.Vars(r) diff --git a/server/api_test.go b/server/api_test.go index 4f1f45e..003ae2a 100644 --- a/server/api_test.go +++ b/server/api_test.go @@ -1,3 +1,62 @@ package main -// TODO: Implement me! +import ( + "net/http" + "net/http/httptest" + "testing" + + pluginapi "github.com/mattermost/mattermost-plugin-api" + "github.com/mattermost/mattermost-plugin-legal-hold/server/config" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/plugin/plugintest" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestTestAmazonS3Connection(t *testing.T) { + p := &Plugin{} + api := &plugintest.API{} + p.SetDriver(&plugintest.Driver{}) + p.SetAPI(api) + p.Client = pluginapi.NewClient(p.API, p.Driver) + + api.On("HasPermissionTo", "test_user_id", model.PermissionManageSystem).Return(true) + api.On("LogInfo", mock.Anything).Maybe() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/bucket/" { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + + defer server.Close() + + p.setConfiguration(&config.Configuration{ + TimeOfDay: "10:00pm -0500h", + AmazonS3BucketSettings: config.AmazonS3BucketSettings{ + Enable: true, + Settings: model.FileSettings{ + DriverName: model.NewString("amazons3"), + AmazonS3Bucket: model.NewString("bucket"), + AmazonS3AccessKeyId: model.NewString("access_key_id"), + AmazonS3SecretAccessKey: model.NewString("secret_access_key"), + AmazonS3RequestTimeoutMilliseconds: model.NewInt64(5000), + AmazonS3Endpoint: model.NewString(server.Listener.Addr().String()), + AmazonS3Region: model.NewString("us-east-1"), + AmazonS3SSL: model.NewBool(false), + AmazonS3SSE: model.NewBool(false), + }, + }, + }) + + req, err := http.NewRequest(http.MethodPost, "/api/v1/test_amazon_s3_connection", nil) + require.NoError(t, err) + + req.Header.Add("Mattermost-User-Id", "test_user_id") + + recorder := httptest.NewRecorder() + p.ServeHTTP(nil, recorder, req) + require.Equal(t, http.StatusOK, recorder.Code) +} diff --git a/server/config/configuration.go b/server/config/configuration.go index c00528d..f841e61 100644 --- a/server/config/configuration.go +++ b/server/config/configuration.go @@ -1,5 +1,7 @@ package config +import "github.com/mattermost/mattermost-server/v6/model" + // Configuration captures the plugin's external Configuration as exposed in the Mattermost server // Configuration, as well as values computed from the Configuration. Any public fields will be // deserialized from the Mattermost server Configuration in OnConfigurationChange. @@ -12,7 +14,13 @@ package config // If you add non-reference types to your Configuration struct, be sure to rewrite Clone as a deep // copy appropriate for your types. type Configuration struct { - TimeOfDay string + TimeOfDay string + AmazonS3BucketSettings AmazonS3BucketSettings +} + +type AmazonS3BucketSettings struct { + Enable bool + Settings model.FileSettings } // Clone shallow copies the Configuration. Your implementation may require a deep copy if diff --git a/server/plugin.go b/server/plugin.go index ccb32a1..48657b2 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -158,14 +158,28 @@ func (p *Plugin) Reconfigure() error { return nil } + conf := p.getConfiguration() + + serverFileSettings := p.Client.Configuration.GetUnsanitizedConfig().FileSettings + if conf.AmazonS3BucketSettings.Enable { + serverFileSettings = conf.AmazonS3BucketSettings.Settings + } + // Reinitialise the filestore backend // FIXME: Boolean flags shouldn't be hard coded. - filesBackendSettings := FixedFileSettingsToFileBackendSettings(p.Client.Configuration.GetUnsanitizedConfig().FileSettings, false, true) + filesBackendSettings := FixedFileSettingsToFileBackendSettings(serverFileSettings, false, true) filesBackend, err := filestore.NewFileBackend(filesBackendSettings) if err != nil { p.Client.Log.Error("unable to initialize the files storage", "err", err) return errors.New("unable to initialize the files storage") } + + if err = filesBackend.TestConnection(); err != nil { + err = errors.Wrap(err, "connection test for filestore failed") + p.Client.Log.Error(err.Error()) + return err + } + p.FileBackend = filesBackend // Remove old job if exists diff --git a/webapp/src/client.ts b/webapp/src/client.ts index a35f8f1..b1fb0b4 100644 --- a/webapp/src/client.ts +++ b/webapp/src/client.ts @@ -32,7 +32,12 @@ class APIClient { return this.doPost(url, data); }; - doGet = async (url: string, headers = {}) => { + testAmazonS3Connection = () => { + const url = `${this.url}/test_amazon_s3_connection`; + return this.doPost(url, {}) as Promise<{message: string}>; + }; + + private doGet = async (url: string, headers = {}) => { const options = { method: 'get', headers, @@ -53,7 +58,7 @@ class APIClient { }); }; - doPost = async (url: string, body: any, headers = {}) => { + private doPost = async (url: string, body: any, headers = {}) => { const options = { method: 'post', body: JSON.stringify(body), diff --git a/webapp/src/components/admin_console_settings/base_setting.tsx b/webapp/src/components/admin_console_settings/base_setting.tsx new file mode 100644 index 0000000..6b756af --- /dev/null +++ b/webapp/src/components/admin_console_settings/base_setting.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +type Props = React.PropsWithChildren<{ + id: string; + name: string; + helpText: string; +}>; + +const BaseSetting = (props: Props) => { + return ( +
+ +
+ {props.children} +
+ {props.helpText} +
+
+
+ ); +}; + +export default BaseSetting; diff --git a/webapp/src/components/admin_console_settings/boolean_setting.tsx b/webapp/src/components/admin_console_settings/boolean_setting.tsx new file mode 100644 index 0000000..f4aba10 --- /dev/null +++ b/webapp/src/components/admin_console_settings/boolean_setting.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import BaseSetting from './base_setting'; + +type Props = { + id: string; + name: string; + helpText: string; + onChange: (value: boolean) => void; + value: boolean; + disabled?: boolean; +}; + +const BooleanSetting = (props: Props) => { + return ( + + + + + ); +}; + +export default BooleanSetting; diff --git a/webapp/src/components/admin_console_settings/secret_text_setting.tsx b/webapp/src/components/admin_console_settings/secret_text_setting.tsx new file mode 100644 index 0000000..2bc4674 --- /dev/null +++ b/webapp/src/components/admin_console_settings/secret_text_setting.tsx @@ -0,0 +1,51 @@ +import React, {useEffect, useRef, useState} from 'react'; + +import BaseSetting from './base_setting'; + +type Props = { + id: string; + name: string; + helpText: string; + onChange: (value: string) => void; + value: string; + disabled?: boolean; +}; + +const SecretTextSetting = (props: Props) => { + const [value, setValue] = useState(''); + const mounted = useRef(false); + + useEffect(() => { + if (mounted.current) { + setValue(props.value); + return; + } + + if (props.value) { + setValue('*'.repeat(32)); + } + + mounted.current = true; + }, [props.value]); + + const handleChange = (newValue: string) => { + setValue(newValue); + }; + + return ( + + handleChange(e.target.value)} + disabled={props.disabled} + /> + + ); +}; + +export default SecretTextSetting; diff --git a/webapp/src/components/admin_console_settings/status_message.tsx b/webapp/src/components/admin_console_settings/status_message.tsx new file mode 100644 index 0000000..86200d4 --- /dev/null +++ b/webapp/src/components/admin_console_settings/status_message.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +type Props = { + state: 'warn' | 'success'; + message: string; +} + +const StatusMessage = (props: Props) => { + const {state, message} = props; + + if (state === 'warn') { + return ( +
+
+ + {message} +
+
+ ); + } + + return ( +
+
+ + {message} +
+
+ ); +}; + +export default StatusMessage; diff --git a/webapp/src/components/admin_console_settings/text_setting.tsx b/webapp/src/components/admin_console_settings/text_setting.tsx new file mode 100644 index 0000000..6257848 --- /dev/null +++ b/webapp/src/components/admin_console_settings/text_setting.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import BaseSetting from './base_setting'; + +type Props = { + id: string; + name: string; + helpText: string; + onChange: (value: string) => void; + value: string; + disabled?: boolean; +}; + +const TextSetting = (props: Props) => { + return ( + + props.onChange(e.target.value)} + disabled={props.disabled} + /> + + ); +}; + +export default TextSetting; diff --git a/webapp/src/components/amazon_s3_bucket_settings.tsx b/webapp/src/components/amazon_s3_bucket_settings.tsx new file mode 100644 index 0000000..d34de80 --- /dev/null +++ b/webapp/src/components/amazon_s3_bucket_settings.tsx @@ -0,0 +1,233 @@ +import React, {useEffect, useMemo, useState} from 'react'; + +import {IntlProvider} from 'react-intl'; + +import Client from '@/client'; + +import BooleanSetting from './admin_console_settings/boolean_setting'; +import TextSetting from './admin_console_settings/text_setting'; +import SaveButton from './mattermost-webapp/save_button'; +import BaseSetting from './admin_console_settings/base_setting'; +import StatusMessage from './admin_console_settings/status_message'; +import SecretTextSetting from './admin_console_settings/secret_text_setting'; + +type FileSettings = { + DriverName: string; + AmazonS3RequestTimeoutMilliseconds: number; + AmazonS3Bucket: string; + AmazonS3PathPrefix: string; + AmazonS3Region: string; + AmazonS3Endpoint: string; + AmazonS3AccessKeyId: string; + AmazonS3SecretAccessKey: string; + AmazonS3SSL: boolean; + AmazonS3SSE: boolean; +}; + +type AmazonS3BucketSettingsData = { + Enable: boolean; + Settings: FileSettings; +}; + +const useS3BucketForm = (initialValue: AmazonS3BucketSettingsData | undefined, onChange: (id: string, value: AmazonS3BucketSettingsData) => void) => { + const [formState, setFormState] = useState({ + Enable: initialValue?.Enable ?? false, + Settings: { + DriverName: 'amazons3', + AmazonS3RequestTimeoutMilliseconds: 30000, + AmazonS3Bucket: '', + AmazonS3PathPrefix: '', + AmazonS3Region: '', + AmazonS3Endpoint: '', + AmazonS3AccessKeyId: '', + AmazonS3SecretAccessKey: '', + AmazonS3SSL: false, + AmazonS3SSE: false, + ...initialValue?.Settings, + }, + }); + + return useMemo(() => ({ + formState, + setEnable: (value: boolean) => { + const newState = { + ...formState, + Enable: value, + }; + setFormState(newState); + onChange('PluginSettings.Plugins.com+mattermost+plugin-legal-hold.amazons3bucketsettings', newState); + }, + setFormValue: (key: T, value: FileSettings[T]) => { + const newState = { + ...formState, + Settings: { + ...formState.Settings, + [key]: value, + }, + }; + + setFormState(newState); + onChange('PluginSettings.Plugins.com+mattermost+plugin-legal-hold.amazons3bucketsettings', newState); + }, + }), [formState, setFormState, onChange]); +}; + +type Props = { + value: AmazonS3BucketSettingsData | undefined; + onChange: (id: string, value: AmazonS3BucketSettingsData) => void; +}; + +const isSettingFormDirty = () => { + const submitButton = document.querySelector('button#saveSetting') as HTMLButtonElement | null; + if (submitButton) { + return !submitButton.disabled; + } + + return false; +}; + +const AmazonS3BucketSettings = (props: Props) => { + const {formState, setFormValue, setEnable} = useS3BucketForm(props.value, props.onChange); + const [testingConnection, setTestingConnection] = useState(false); + + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + + const s3Settings = formState.Settings; + + const testConnection = async () => { + if (isSettingFormDirty()) { + setError('Please save the settings before testing the connection.'); + return; + } + + setMessage(''); + setError(''); + + setTestingConnection(true); + + try { + const res = await Client.testAmazonS3Connection(); + if (res.message) { + setMessage(res.message); + } + } catch (err) { + if ('message' in (err as Error)) { + setError((err as Error).message); + } + } + + setTestingConnection(false); + }; + + let statusMessage: React.ReactNode | undefined; + if (error) { + statusMessage = ( + + ); + } else if (message) { + statusMessage = ( + + ); + } + + return ( + + setEnable(value)} + /> + setFormValue('AmazonS3Bucket', value)} + disabled={!formState.Enable} + /> + setFormValue('AmazonS3PathPrefix', value)} + disabled={!formState.Enable} + /> + setFormValue('AmazonS3Region', value)} + disabled={!formState.Enable} + /> + setFormValue('AmazonS3AccessKeyId', value)} + disabled={!formState.Enable} + /> + setFormValue('AmazonS3Endpoint', value)} + disabled={!formState.Enable} + /> + setFormValue('AmazonS3SecretAccessKey', value)} + disabled={!formState.Enable} + /> + setFormValue('AmazonS3SSL', value)} + disabled={!formState.Enable} + /> + setFormValue('AmazonS3SSE', value)} + disabled={!formState.Enable} + /> + + + {statusMessage} + + + ); +}; + +export default AmazonS3BucketSettings; diff --git a/webapp/src/components/legal_holds_setting.tsx b/webapp/src/components/legal_holds_setting.tsx index 5f04ded..c7a1c00 100644 --- a/webapp/src/components/legal_holds_setting.tsx +++ b/webapp/src/components/legal_holds_setting.tsx @@ -94,6 +94,7 @@ const LegalHoldsSetting = () => { background: 'var(--sys-center-channel-bg)', borderRadius: '4px', boxShadow: '0 2px 3px rgba(0, 0, 0, 0.08)', + marginBottom: '24px', }} >
) => void; @@ -20,6 +21,7 @@ type Props = { // eslint-disable-next-line react/prefer-stateless-function export default class SaveButton extends React.PureComponent { public static defaultProps: Partial = { + type: 'submit', btnClass: '', defaultMessage: ( { defaultMessage, btnClass, extraClasses, + type, ...props } = this.props; @@ -63,9 +66,7 @@ export default class SaveButton extends React.PureComponent { return (