From b82d856d1cbe91d74c7c8fe26e3ca5869c5e6e3a Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Fri, 13 Mar 2026 10:50:03 -0400 Subject: [PATCH 1/7] Add channel settings tab smoke test and fix GetTeams resilience Register a ChannelSettingsTab pluggable for smoke-testing the new channel settings tab API. The component renders channel info, a dirty-state input, and a tab-switch error banner. Make GetTeams() calls non-fatal in ensureDemoUser, ensureDemoChannels, OnActivate, and OnDeactivate so the plugin can start even when the Teams table has NULL string columns (core server bug). Co-Authored-By: Claude Opus 4.6 (1M context) --- server/activate_hooks.go | 52 +++++++++---------- server/configuration.go | 17 +++--- .../channel_settings_smoke_test.jsx | 52 +++++++++++++++++++ webapp/src/plugin.jsx | 7 +++ 4 files changed, 94 insertions(+), 34 deletions(-) create mode 100644 webapp/src/components/channel_settings_smoke_test.jsx diff --git a/server/activate_hooks.go b/server/activate_hooks.go index 35f3ec6..6fabe6e 100644 --- a/server/activate_hooks.go +++ b/server/activate_hooks.go @@ -38,19 +38,19 @@ func (p *Plugin) OnActivate() error { teams, err := p.API.GetTeams() if err != nil { - return errors.Wrap(err, "failed to query teams OnActivate") - } - - for _, team := range teams { - _, ok := configuration.demoChannelIDs[team.Id] - if !ok { - p.API.LogWarn("No demo channel id for team", "team", team.Id) - continue - } - - msg := fmt.Sprintf("OnActivate: %s", manifest.Id) - if err := p.postPluginMessage(team.Id, msg); err != nil { - return errors.Wrap(err, "failed to post OnActivate message") + p.API.LogWarn("Failed to query teams OnActivate, skipping activation messages", "error", err.Error()) + } else { + for _, team := range teams { + _, ok := configuration.demoChannelIDs[team.Id] + if !ok { + p.API.LogWarn("No demo channel id for team", "team", team.Id) + continue + } + + msg := fmt.Sprintf("OnActivate: %s", manifest.Id) + if err := p.postPluginMessage(team.Id, msg); err != nil { + p.API.LogWarn("Failed to post OnActivate message", "error", err.Error()) + } } } @@ -83,19 +83,19 @@ func (p *Plugin) OnDeactivate() error { teams, err := p.API.GetTeams() if err != nil { - return errors.Wrap(err, "failed to query teams OnDeactivate") - } - - for _, team := range teams { - _, ok := configuration.demoChannelIDs[team.Id] - if !ok { - p.API.LogWarn("No demo channel id for team", "team", team.Id) - continue - } - - msg := fmt.Sprintf("OnDeactivate: %s", manifest.Id) - if err := p.postPluginMessage(team.Id, msg); err != nil { - return errors.Wrap(err, "failed to post OnDeactivate message") + p.API.LogWarn("Failed to query teams OnDeactivate, skipping deactivation messages", "error", err.Error()) + } else { + for _, team := range teams { + _, ok := configuration.demoChannelIDs[team.Id] + if !ok { + p.API.LogWarn("No demo channel id for team", "team", team.Id) + continue + } + + msg := fmt.Sprintf("OnDeactivate: %s", manifest.Id) + if err := p.postPluginMessage(team.Id, msg); err != nil { + p.API.LogWarn("Failed to post OnDeactivate message", "error", err.Error()) + } } } diff --git a/server/configuration.go b/server/configuration.go index e0dac42..60a059f 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -401,13 +401,13 @@ func (p *Plugin) ensureDemoUser(configuration *configuration) (string, error) { teams, err := p.API.GetTeams() if err != nil { - return "", err - } - - for _, team := range teams { - _, err := p.API.CreateTeamMember(team.Id, user.Id) - if err != nil { - p.API.LogError("Failed add demo user to team", "teamID", team.Id, "error", err.Error()) + p.API.LogWarn("Failed to get teams for demo user setup, skipping team membership", "error", err.Error()) + } else { + for _, team := range teams { + _, err := p.API.CreateTeamMember(team.Id, user.Id) + if err != nil { + p.API.LogError("Failed add demo user to team", "teamID", team.Id, "error", err.Error()) + } } } @@ -417,7 +417,8 @@ func (p *Plugin) ensureDemoUser(configuration *configuration) (string, error) { func (p *Plugin) ensureDemoChannels(configuration *configuration) (map[string]string, error) { teams, err := p.API.GetTeams() if err != nil { - return nil, err + p.API.LogWarn("Failed to get teams for demo channel setup, skipping channel creation", "error", err.Error()) + return make(map[string]string), nil } demoChannelIDs := make(map[string]string) diff --git a/webapp/src/components/channel_settings_smoke_test.jsx b/webapp/src/components/channel_settings_smoke_test.jsx new file mode 100644 index 0000000..c23fe6c --- /dev/null +++ b/webapp/src/components/channel_settings_smoke_test.jsx @@ -0,0 +1,52 @@ +import React, {useCallback, useState} from 'react'; +import PropTypes from 'prop-types'; + +export default function ChannelSettingsSmokeTest({channel, setAreThereUnsavedChanges, showTabSwitchError}) { + const [value, setValue] = useState(''); + + const handleChange = useCallback((e) => { + const newValue = e.target.value; + setValue(newValue); + setAreThereUnsavedChanges?.(newValue.length > 0); + }, [setAreThereUnsavedChanges]); + + return ( +
+

{'Channel Settings Smoke Test'}

+
+ {'Display Name: '}{channel.display_name} +
+
+ {'Channel Name: '}{channel.name} +
+
+ {'Channel ID: '}{channel.id} +
+
+ +
+ +
+ {showTabSwitchError && ( +
+ {'You have unsaved changes. Please save or discard before switching tabs.'} +
+ )} +
+ ); +} + +ChannelSettingsSmokeTest.propTypes = { + channel: PropTypes.object.isRequired, + setAreThereUnsavedChanges: PropTypes.func, + showTabSwitchError: PropTypes.bool, +}; diff --git a/webapp/src/plugin.jsx b/webapp/src/plugin.jsx index 5be61d2..a4c812a 100644 --- a/webapp/src/plugin.jsx +++ b/webapp/src/plugin.jsx @@ -18,6 +18,7 @@ import RHSView from './components/right_hand_sidebar'; import SecretMessageSetting from './components/admin_settings/secret_message_setting'; import CustomSetting from './components/admin_settings/custom_setting'; import FilePreviewOverride from './components/file_preview_override'; +import ChannelSettingsSmokeTest from './components/channel_settings_smoke_test'; import RouterShowcase from './components/router_showcase/router_showcase'; import PostType from './components/post_type'; import EphemeralPostType from './components/ephemeral_post_type'; @@ -50,6 +51,12 @@ function getTranslations(locale) { export default class DemoPlugin { initialize(registry, store) { + registry.registerChannelSettingsTab?.({ + uiName: 'Smoke Test', + component: ChannelSettingsSmokeTest, + shouldRender: () => true, + }); + registry.registerRootComponent(Root); registry.registerPopoverUserAttributesComponent(UserAttributes); registry.registerPopoverUserActionsComponent(UserActions); From 47d11b0cbc91045f43f6ef7d2731524ac2e39902 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Fri, 13 Mar 2026 10:54:52 -0400 Subject: [PATCH 2/7] Rename channel settings tab from 'Smoke Test' to 'Demo Plugin' Co-Authored-By: Claude Opus 4.6 (1M context) --- webapp/src/plugin.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/plugin.jsx b/webapp/src/plugin.jsx index a4c812a..d0a65fc 100644 --- a/webapp/src/plugin.jsx +++ b/webapp/src/plugin.jsx @@ -52,7 +52,7 @@ function getTranslations(locale) { export default class DemoPlugin { initialize(registry, store) { registry.registerChannelSettingsTab?.({ - uiName: 'Smoke Test', + uiName: 'Demo Plugin', component: ChannelSettingsSmokeTest, shouldRender: () => true, }); From 2b819ee11488ce3e045f13d1ad7d32ca85d90b16 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 25 Mar 2026 14:22:40 -0400 Subject: [PATCH 3/7] Channel settings tab: host save bar API and fa-plug sidebar icon - Register save/reset via registerSaveBarHandlers; remove inline tab-switch banner - Set icon to fa fa-plug to match other demo plugin surfaces (MainMenu, channel header, etc.) Made-with: Cursor --- .../channel_settings_smoke_test.jsx | 34 ++++++++++++++----- webapp/src/plugin.jsx | 1 + 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/webapp/src/components/channel_settings_smoke_test.jsx b/webapp/src/components/channel_settings_smoke_test.jsx index c23fe6c..e317c2f 100644 --- a/webapp/src/components/channel_settings_smoke_test.jsx +++ b/webapp/src/components/channel_settings_smoke_test.jsx @@ -1,9 +1,32 @@ -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import PropTypes from 'prop-types'; -export default function ChannelSettingsSmokeTest({channel, setAreThereUnsavedChanges, showTabSwitchError}) { +export default function ChannelSettingsSmokeTest({channel, setAreThereUnsavedChanges, registerSaveBarHandlers}) { const [value, setValue] = useState(''); + const handleSave = useCallback(async () => { + // Smoke test: no server persistence; clearing dirty state matches a successful save. + setAreThereUnsavedChanges?.(false); + }, [setAreThereUnsavedChanges]); + + const handleReset = useCallback(() => { + setValue(''); + setAreThereUnsavedChanges?.(false); + }, [setAreThereUnsavedChanges]); + + /* eslint-disable consistent-return -- useEffect may return cleanup or nothing */ + useEffect(() => { + if (!registerSaveBarHandlers) { + return; + } + registerSaveBarHandlers({ + save: handleSave, + reset: handleReset, + }); + return () => registerSaveBarHandlers(null); + }, [registerSaveBarHandlers, handleSave, handleReset]); + /* eslint-enable consistent-return */ + const handleChange = useCallback((e) => { const newValue = e.target.value; setValue(newValue); @@ -36,11 +59,6 @@ export default function ChannelSettingsSmokeTest({channel, setAreThereUnsavedCha style={{marginTop: '4px', padding: '6px', width: '300px'}} /> - {showTabSwitchError && ( -
- {'You have unsaved changes. Please save or discard before switching tabs.'} -
- )} ); } @@ -48,5 +66,5 @@ export default function ChannelSettingsSmokeTest({channel, setAreThereUnsavedCha ChannelSettingsSmokeTest.propTypes = { channel: PropTypes.object.isRequired, setAreThereUnsavedChanges: PropTypes.func, - showTabSwitchError: PropTypes.bool, + registerSaveBarHandlers: PropTypes.func, }; diff --git a/webapp/src/plugin.jsx b/webapp/src/plugin.jsx index d0a65fc..878e68b 100644 --- a/webapp/src/plugin.jsx +++ b/webapp/src/plugin.jsx @@ -54,6 +54,7 @@ export default class DemoPlugin { registry.registerChannelSettingsTab?.({ uiName: 'Demo Plugin', component: ChannelSettingsSmokeTest, + icon: 'fa fa-plug', shouldRender: () => true, }); From 072824553a073a647e12e1b8855fb5d738b7bb6c Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Wed, 25 Mar 2026 14:24:47 -0400 Subject: [PATCH 4/7] Use plugin public icon for channel settings tab (match user settings) Made-with: Cursor --- webapp/src/plugin.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/plugin.jsx b/webapp/src/plugin.jsx index 878e68b..f4c36aa 100644 --- a/webapp/src/plugin.jsx +++ b/webapp/src/plugin.jsx @@ -54,7 +54,7 @@ export default class DemoPlugin { registry.registerChannelSettingsTab?.({ uiName: 'Demo Plugin', component: ChannelSettingsSmokeTest, - icon: 'fa fa-plug', + icon: `/plugins/${manifest.id}/public/icon.png`, shouldRender: () => true, }); From 0db03b5b5e0ba900eda3e00cc01d9d5fae165cbc Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Fri, 5 Jun 2026 16:09:02 -0400 Subject: [PATCH 5/7] feat: rewrite channel settings to schema-based webapp API (#212) * feat: rewrite channel settings to schema-based webapp API Migrate the demo plugin from the old custom-only channel settings API (registerSaveBarHandlers) to the new schema-based registry API so the plugin serves as a reference exemplar for both registration styles. Register two channel settings tabs: - A declarative schema tab ("Demo Channel Settings") with a Posting section (two radio settings + a custom accent-color setting) and a custom Channel info section. shouldRender hides it in DM/GM, the host owns the save bar, and persistence flows through onSave/loadValues. - A fully custom tab ("Demo Advanced (Custom)") where the plugin renders the entire body, registers its own save/reset handlers, and reports dirty state via setUnsaved. Persistence is backed by new plugin HTTP endpoints over the KV store (GET/POST /channel_settings/{channel_id}/schema and /custom) guarded by read (PermissionReadChannel) and write (Manage{Public,Private} ChannelProperties) authorization. Removes the old smoke-test component. Co-authored-by: Cursor * fix: make custom channel settings textarea theme-aware Theme the "Channel note" textarea via Mattermost CSS variables so it renders correctly on any theme, and add a visible translatable placeholder (injectIntl) so it's readable on dark themes. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- go.mod | 1 + server/channel_settings_api.go | 154 +++++++++++++++++ server/channel_settings_api_test.go | 158 +++++++++++++++++ server/http_hooks.go | 6 + webapp/i18n/en.json | 10 ++ webapp/package-lock.json | 19 +- .../channel_settings/accent_color_setting.tsx | 83 +++++++++ .../channel_settings/channel_info_section.tsx | 44 +++++ webapp/src/channel_settings/client.ts | 50 ++++++ webapp/src/channel_settings/custom_tab.tsx | 163 ++++++++++++++++++ webapp/src/channel_settings/register.ts | 27 +++ webapp/src/channel_settings/schema_tab.tsx | 61 +++++++ webapp/src/channel_settings/types.ts | 85 +++++++++ .../channel_settings_smoke_test.jsx | 70 -------- webapp/src/plugin.jsx | 9 +- 15 files changed, 862 insertions(+), 78 deletions(-) create mode 100644 server/channel_settings_api.go create mode 100644 server/channel_settings_api_test.go create mode 100644 webapp/src/channel_settings/accent_color_setting.tsx create mode 100644 webapp/src/channel_settings/channel_info_section.tsx create mode 100644 webapp/src/channel_settings/client.ts create mode 100644 webapp/src/channel_settings/custom_tab.tsx create mode 100644 webapp/src/channel_settings/register.ts create mode 100644 webapp/src/channel_settings/schema_tab.tsx create mode 100644 webapp/src/channel_settings/types.ts delete mode 100644 webapp/src/components/channel_settings_smoke_test.jsx diff --git a/go.mod b/go.mod index 14ee054..eac6bc0 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/russellhaering/goxmldsig v1.5.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/tinylib/msgp v1.4.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/server/channel_settings_api.go b/server/channel_settings_api.go new file mode 100644 index 0000000..59ec2e2 --- /dev/null +++ b/server/channel_settings_api.go @@ -0,0 +1,154 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/mattermost/mattermost/server/public/model" +) + +// KV keys are kept well under the 50-char limit (channel IDs are 26 chars). +func csSchemaKey(channelID string) string { return "cs_schema_" + channelID } +func csCustomKey(channelID string) string { return "cs_custom_" + channelID } + +// customState is the typed payload for the fully-custom channel settings tab. +type customState struct { + Note string `json:"note"` + PinGreeting bool `json:"pinGreeting"` +} + +// requireUser enforces that the request is from an authenticated user. It +// returns false if it already wrote an error response. +func (p *Plugin) requireUser(w http.ResponseWriter, r *http.Request) bool { + if r.Header.Get("Mattermost-User-ID") == "" { + http.Error(w, "Not authorized", http.StatusUnauthorized) + return false + } + return true +} + +// requireChannelReader enforces auth and read access to the channel for read +// paths. It returns the authenticated user ID, or false if it already wrote an +// error response. +func (p *Plugin) requireChannelReader(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) { + if !p.requireUser(w, r) { + return "", false + } + userID := r.Header.Get("Mattermost-User-ID") + + if !p.API.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) { + http.Error(w, "Forbidden", http.StatusForbidden) + return "", false + } + + return userID, true +} + +// requireChannelManager enforces auth and channel-properties permission for +// write paths. It returns the authenticated user ID, or false if it already +// wrote an error response. +func (p *Plugin) requireChannelManager(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) { + if !p.requireUser(w, r) { + return "", false + } + userID := r.Header.Get("Mattermost-User-ID") + + channel, appErr := p.API.GetChannel(channelID) + if appErr != nil { + p.API.LogError("Failed to get channel for channel settings", "channel_id", channelID, "err", appErr.Error()) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return "", false + } + + permission := model.PermissionManagePublicChannelProperties + if channel.Type == model.ChannelTypePrivate { + permission = model.PermissionManagePrivateChannelProperties + } + + if !p.API.HasPermissionToChannel(userID, channelID, permission) { + http.Error(w, "Forbidden", http.StatusForbidden) + return "", false + } + + return userID, true +} + +func (p *Plugin) handleGetChannelSettingsSchema(w http.ResponseWriter, r *http.Request) { + channelID := mux.Vars(r)["channel_id"] + if _, ok := p.requireChannelReader(w, r, channelID); !ok { + return + } + + values := map[string]string{} + if err := p.client.KV.Get(csSchemaKey(channelID), &values); err != nil { + p.API.LogError("Failed to get channel settings schema", "channel_id", channelID, "err", err.Error()) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if values == nil { + values = map[string]string{} + } + + p.writeJSON(w, values) +} + +func (p *Plugin) handleSaveChannelSettingsSchema(w http.ResponseWriter, r *http.Request) { + channelID := mux.Vars(r)["channel_id"] + if _, ok := p.requireChannelManager(w, r, channelID); !ok { + return + } + + var values map[string]string + if err := json.NewDecoder(r.Body).Decode(&values); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if _, err := p.client.KV.Set(csSchemaKey(channelID), values); err != nil { + p.API.LogError("Failed to save channel settings schema", "channel_id", channelID, "err", err.Error()) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + p.writeJSON(w, values) +} + +func (p *Plugin) handleGetChannelSettingsCustom(w http.ResponseWriter, r *http.Request) { + channelID := mux.Vars(r)["channel_id"] + if _, ok := p.requireChannelReader(w, r, channelID); !ok { + return + } + + var state customState + if err := p.client.KV.Get(csCustomKey(channelID), &state); err != nil { + p.API.LogError("Failed to get channel settings custom state", "channel_id", channelID, "err", err.Error()) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + p.writeJSON(w, state) +} + +func (p *Plugin) handleSaveChannelSettingsCustom(w http.ResponseWriter, r *http.Request) { + channelID := mux.Vars(r)["channel_id"] + if _, ok := p.requireChannelManager(w, r, channelID); !ok { + return + } + + var state customState + if err := json.NewDecoder(r.Body).Decode(&state); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + if _, err := p.client.KV.Set(csCustomKey(channelID), state); err != nil { + p.API.LogError("Failed to save channel settings custom state", "channel_id", channelID, "err", err.Error()) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + p.writeJSON(w, state) +} diff --git a/server/channel_settings_api_test.go b/server/channel_settings_api_test.go new file mode 100644 index 0000000..fb64b08 --- /dev/null +++ b/server/channel_settings_api_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const testChannelID = "channelid1234567890123456a" + +func newChannelSettingsPlugin(api *plugintest.API) *Plugin { + p := &Plugin{} + p.SetAPI(api) + p.client = pluginapi.NewClient(api, nil) + p.initializeAPI() + return p +} + +func TestChannelSettingsSchemaRoundTrip(t *testing.T) { + api := &plugintest.API{} + defer api.AssertExpectations(t) + + store := map[string][]byte{} + api.On("KVSetWithOptions", "cs_schema_"+testChannelID, mock.Anything, mock.Anything).Return(true, nil).Run(func(args mock.Arguments) { + store[args.String(0)] = args.Get(1).([]byte) + }) + api.On("KVGet", "cs_schema_"+testChannelID).Return(func(key string) []byte { return store[key] }, nil) + api.On("GetChannel", testChannelID).Return(&model.Channel{Id: testChannelID, Type: model.ChannelTypeOpen}, nil) + api.On("HasPermissionToChannel", "user1", testChannelID, model.PermissionManagePublicChannelProperties).Return(true) + api.On("HasPermissionToChannel", "user1", testChannelID, model.PermissionReadChannel).Return(true) + + p := newChannelSettingsPlugin(api) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/channel_settings/"+testChannelID+"/schema", strings.NewReader(`{"postPrefixStyle":"bold"}`)) + r.Header.Set("Mattermost-User-ID", "user1") + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + w = httptest.NewRecorder() + r = httptest.NewRequest(http.MethodGet, "/channel_settings/"+testChannelID+"/schema", nil) + r.Header.Set("Mattermost-User-ID", "user1") + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + require.JSONEq(t, `{"postPrefixStyle":"bold"}`, w.Body.String()) +} + +func TestChannelSettingsCustomRoundTrip(t *testing.T) { + api := &plugintest.API{} + defer api.AssertExpectations(t) + + store := map[string][]byte{} + api.On("KVSetWithOptions", "cs_custom_"+testChannelID, mock.Anything, mock.Anything).Return(true, nil).Run(func(args mock.Arguments) { + store[args.String(0)] = args.Get(1).([]byte) + }) + api.On("KVGet", "cs_custom_"+testChannelID).Return(func(key string) []byte { return store[key] }, nil) + api.On("GetChannel", testChannelID).Return(&model.Channel{Id: testChannelID, Type: model.ChannelTypeOpen}, nil) + api.On("HasPermissionToChannel", "user1", testChannelID, model.PermissionManagePublicChannelProperties).Return(true) + api.On("HasPermissionToChannel", "user1", testChannelID, model.PermissionReadChannel).Return(true) + + p := newChannelSettingsPlugin(api) + + // GET before any save returns the zero-valued custom state. + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/channel_settings/"+testChannelID+"/custom", nil) + r.Header.Set("Mattermost-User-ID", "user1") + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + require.JSONEq(t, `{"note":"","pinGreeting":false}`, w.Body.String()) + + w = httptest.NewRecorder() + r = httptest.NewRequest(http.MethodPost, "/channel_settings/"+testChannelID+"/custom", strings.NewReader(`{"note":"hello","pinGreeting":true}`)) + r.Header.Set("Mattermost-User-ID", "user1") + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + w = httptest.NewRecorder() + r = httptest.NewRequest(http.MethodGet, "/channel_settings/"+testChannelID+"/custom", nil) + r.Header.Set("Mattermost-User-ID", "user1") + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + require.JSONEq(t, `{"note":"hello","pinGreeting":true}`, w.Body.String()) +} + +func TestChannelSettingsReadAuthorization(t *testing.T) { + t.Run("get without auth is unauthorized", func(t *testing.T) { + api := &plugintest.API{} + p := newChannelSettingsPlugin(api) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/channel_settings/"+testChannelID+"/schema", nil) + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusUnauthorized, w.Result().StatusCode) + }) + + t.Run("get without read permission is forbidden", func(t *testing.T) { + api := &plugintest.API{} + defer api.AssertExpectations(t) + api.On("HasPermissionToChannel", "user1", testChannelID, model.PermissionReadChannel).Return(false) + + p := newChannelSettingsPlugin(api) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/channel_settings/"+testChannelID+"/custom", nil) + r.Header.Set("Mattermost-User-ID", "user1") + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusForbidden, w.Result().StatusCode) + }) +} + +func TestChannelSettingsMalformedBody(t *testing.T) { + api := &plugintest.API{} + defer api.AssertExpectations(t) + api.On("GetChannel", testChannelID).Return(&model.Channel{Id: testChannelID, Type: model.ChannelTypeOpen}, nil) + api.On("HasPermissionToChannel", "user1", testChannelID, model.PermissionManagePublicChannelProperties).Return(true) + + p := newChannelSettingsPlugin(api) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/channel_settings/"+testChannelID+"/custom", strings.NewReader(`{not json`)) + r.Header.Set("Mattermost-User-ID", "user1") + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusBadRequest, w.Result().StatusCode) +} + +func TestChannelSettingsAuthAndPermission(t *testing.T) { + t.Run("save without auth is unauthorized", func(t *testing.T) { + api := &plugintest.API{} + p := newChannelSettingsPlugin(api) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/channel_settings/"+testChannelID+"/schema", strings.NewReader(`{}`)) + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusUnauthorized, w.Result().StatusCode) + }) + + t.Run("save without permission is forbidden", func(t *testing.T) { + api := &plugintest.API{} + defer api.AssertExpectations(t) + api.On("GetChannel", testChannelID).Return(&model.Channel{Id: testChannelID, Type: model.ChannelTypePrivate}, nil) + api.On("HasPermissionToChannel", "user1", testChannelID, model.PermissionManagePrivateChannelProperties).Return(false) + + p := newChannelSettingsPlugin(api) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/channel_settings/"+testChannelID+"/custom", strings.NewReader(`{"note":"x"}`)) + r.Header.Set("Mattermost-User-ID", "user1") + p.ServeHTTP(nil, w, r) + require.Equal(t, http.StatusForbidden, w.Result().StatusCode) + }) +} diff --git a/server/http_hooks.go b/server/http_hooks.go index 37347fc..8e676e4 100644 --- a/server/http_hooks.go +++ b/server/http_hooks.go @@ -61,6 +61,12 @@ func (p *Plugin) initializeAPI() { ephemeralRouter.HandleFunc("/update", p.handleEphemeralUpdate) ephemeralRouter.HandleFunc("/delete", p.handleEphemeralDelete) + channelSettingsRouter := router.PathPrefix("/channel_settings/{channel_id}").Subrouter() + channelSettingsRouter.HandleFunc("/schema", p.handleGetChannelSettingsSchema).Methods(http.MethodGet) + channelSettingsRouter.HandleFunc("/schema", p.handleSaveChannelSettingsSchema).Methods(http.MethodPost) + channelSettingsRouter.HandleFunc("/custom", p.handleGetChannelSettingsCustom).Methods(http.MethodGet) + channelSettingsRouter.HandleFunc("/custom", p.handleSaveChannelSettingsCustom).Methods(http.MethodPost) + p.router = router } diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 7c22b61..c9f271c 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1,4 +1,14 @@ { + "channel_settings.accent_color.title": "Accent color", + "channel_settings.custom_tab.heading": "Advanced channel options", + "channel_settings.custom_tab.load_error": "Could not load these settings. Reopen the tab to try again.", + "channel_settings.custom_tab.loading": "Loading…", + "channel_settings.custom_tab.note_label": "Channel note", + "channel_settings.custom_tab.note_placeholder": "Add a note for this channel…", + "channel_settings.custom_tab.pin_greeting": "Pin greeting message", + "channel_settings.info_section.name": "Channel:", + "channel_settings.info_section.note": "This section is rendered entirely by the demo plugin to showcase a custom settings section.", + "channel_settings.info_section.type": "Type:", "demo.testintl": "This is the English String", "demo.triggeredby": "Element clicked in the menu: ", "plugin.name": "Demo Plugin", diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 97c8846..1e00d2a 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -129,6 +129,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.5.tgz", "integrity": "sha512-Cwc2XjUrG4ilcfOw4wBAK+enbdgwAcAJCfGUItPBKR7Mjw4aEfAFYrLxeRp4jWgtNIKn3n2AlBOfwwafl+42/g==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -2893,6 +2894,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/@mattermost/types/-/types-11.1.0.tgz", "integrity": "sha512-btLuyOHGs0WTB0SFVXzciS4awlyqfvptME6APQkInv1axffs3EhEej52qI+Q2QUgMsqrXRN2IY/VgBG7DjNnwg==", + "peer": true, "peerDependencies": { "typescript": "^4.3.0 || ^5.0.0" }, @@ -3180,6 +3182,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.64.tgz", "integrity": "sha512-MlmPvHgjj2p3vZaxbQgFUQFvD8QiZwACfGqEdDSWou5yISWxDQ4/74nCAwsUiX7UFLKZz3BbVSPj+YxeoGGCfg==", "devOptional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3353,6 +3356,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -3787,6 +3791,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3808,6 +3813,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4426,6 +4432,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -5154,6 +5161,7 @@ "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", "dev": true, + "peer": true, "dependencies": { "array.prototype.flat": "^1.2.3", "cheerio": "^1.0.0-rc.3", @@ -5371,6 +5379,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -5535,6 +5544,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz", "integrity": "sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==", "dev": true, + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -10055,6 +10065,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10118,7 +10129,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "peer": true }, "node_modules/redux-batched-actions": { "version": "0.5.0", @@ -10458,6 +10470,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -11277,6 +11290,7 @@ "version": "4.6.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11443,6 +11457,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -11490,6 +11505,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -11576,6 +11592,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, diff --git a/webapp/src/channel_settings/accent_color_setting.tsx b/webapp/src/channel_settings/accent_color_setting.tsx new file mode 100644 index 0000000..8bcc33c --- /dev/null +++ b/webapp/src/channel_settings/accent_color_setting.tsx @@ -0,0 +1,83 @@ +import React, {useEffect, useRef, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; + +import {fetchSchemaValues} from './client'; + +const SWATCHES = ['#1e88e5', '#43a047', '#e53935', '#8e24aa', '#fb8c00']; + +const ACCENT_COLOR_SETTING = 'accentColor'; + +type Props = { + informChange: (name: string, value: string) => void; +}; + +// The host treats custom settings as uncontrolled: a "Reset" clears the host's +// tracked value but will NOT visually revert this swatch. Acceptable for a demo. +export default function AccentColorSetting({informChange}: Props) { + const channelId = useSelector(getCurrentChannelId); + const [value, setValue] = useState(SWATCHES[0]); + + // The host may pass a new informChange each render; a ref keeps the hydrate + // effect depending only on the channel id so it doesn't refetch/loop. + const informChangeRef = useRef(informChange); + informChangeRef.current = informChange; + + useEffect(() => { + let active = true; + + if (channelId) { + fetchSchemaValues(channelId).then((values) => { + const loaded = values[ACCENT_COLOR_SETTING]; + if (active && loaded) { + setValue(loaded); + + // Sync the host's tracked value with what we hydrated. + informChangeRef.current(ACCENT_COLOR_SETTING, loaded); + } + }).catch(() => { + // Plugin owns its own error surfacing; fall back to the default swatch. + }); + } + + return () => { + active = false; + }; + }, [channelId]); + + const onPick = (color: string) => { + setValue(color); + informChange(ACCENT_COLOR_SETTING, color); + }; + + return ( +
+ +
+ {SWATCHES.map((color) => ( +
+
+ ); +} diff --git a/webapp/src/channel_settings/channel_info_section.tsx b/webapp/src/channel_settings/channel_info_section.tsx new file mode 100644 index 0000000..9e92eab --- /dev/null +++ b/webapp/src/channel_settings/channel_info_section.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; + +export default function ChannelInfoSection() { + const channel = useSelector(getCurrentChannel); + + if (!channel) { + return null; + } + + return ( +
+
+ + + + {' '} + {channel.display_name} +
+
+ + + + {' '} + {channel.type} +
+

+ +

+
+ ); +} diff --git a/webapp/src/channel_settings/client.ts b/webapp/src/channel_settings/client.ts new file mode 100644 index 0000000..e998c96 --- /dev/null +++ b/webapp/src/channel_settings/client.ts @@ -0,0 +1,50 @@ +import {Client4} from 'mattermost-redux/client'; + +import type {Options} from '@mattermost/types/client4'; + +import {id as pluginId} from '../manifest'; + +import type {ChannelSettingsValues} from './types'; + +// Custom tab payload mirrors the server's customState struct. +export type CustomState = { + note: string; + pinGreeting: boolean; +}; + +function pluginRoute(): string { + return `${Client4.getUrl()}/plugins/${pluginId}/channel_settings`; +} + +// Build request options via Client4 so the CSRF token (X-CSRF-Token), credentials, +// and default headers are attached; without them cookie-authed POSTs are rejected +// (401) by the server proxy. Throw on non-2xx so the host keeps the tab dirty. +async function request(url: string, options: Options): Promise { + const res = await fetch(url, Client4.getOptions(options)); + if (!res.ok) { + throw new Error(`channel settings request failed: ${res.status}`); + } + return res.json() as Promise; +} + +export function fetchSchemaValues(channelId: string): Promise { + return request(`${pluginRoute()}/${channelId}/schema`, {method: 'get'}); +} + +export function saveSchemaValues(channelId: string, values: ChannelSettingsValues): Promise { + return request(`${pluginRoute()}/${channelId}/schema`, { + method: 'post', + body: JSON.stringify(values), + }); +} + +export function fetchCustomState(channelId: string): Promise { + return request(`${pluginRoute()}/${channelId}/custom`, {method: 'get'}); +} + +export function saveCustomState(channelId: string, state: CustomState): Promise { + return request(`${pluginRoute()}/${channelId}/custom`, { + method: 'post', + body: JSON.stringify(state), + }); +} diff --git a/webapp/src/channel_settings/custom_tab.tsx b/webapp/src/channel_settings/custom_tab.tsx new file mode 100644 index 0000000..ca0659d --- /dev/null +++ b/webapp/src/channel_settings/custom_tab.tsx @@ -0,0 +1,163 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {FormattedMessage, injectIntl, type InjectedIntlProps} from 'react-intl'; + +import type {WebSocketClient} from '@mattermost/client'; + +import type {Theme} from 'mattermost-redux/selectors/entities/preferences'; + +import {fetchCustomState, saveCustomState, type CustomState} from './client'; +import type {ChannelSettingsTabBodyProps} from './types'; + +// theme/webSocketClient are injected by the host but absent from the public props type. +type Props = ChannelSettingsTabBodyProps & { + theme: Theme; + webSocketClient: WebSocketClient; +}; + +const EMPTY_STATE: CustomState = {note: '', pinGreeting: false}; + +// Inline styles can't target ::placeholder, so theme the textarea via a scoped +// class using the global Mattermost theme CSS variables (works on any theme). +const NOTE_TEXTAREA_CLASS = 'demo-channel-note-textarea'; +const NOTE_TEXTAREA_STYLES = ` +.${NOTE_TEXTAREA_CLASS} { + background: var(--center-channel-bg); + color: var(--center-channel-color); + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + padding: 6px 8px; +} +.${NOTE_TEXTAREA_CLASS}::placeholder { + color: rgba(var(--center-channel-color-rgb), 0.64); +} +`; + +function isDirty(a: CustomState, b: CustomState): boolean { + return a.note !== b.note || a.pinGreeting !== b.pinGreeting; +} + +// @types/react-intl is pinned to v2, which has no useIntl hook; injectIntl is +// the supported way to get a typed formatMessage for the placeholder attribute. +function ChannelSettingsCustomTab({channel, setUnsaved, registerHandlers, theme, intl}: Props & InjectedIntlProps) { + const [baseline, setBaseline] = useState(EMPTY_STATE); + const [state, setState] = useState(EMPTY_STATE); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + + const dirty = useMemo(() => isDirty(state, baseline), [state, baseline]); + + useEffect(() => { + setUnsaved(dirty); + }, [dirty, setUnsaved]); + + useEffect(() => { + let active = true; + setLoading(true); + setLoadError(false); + fetchCustomState(channel.id).then((loaded) => { + if (!active) { + return; + } + setBaseline(loaded); + setState(loaded); + setLoading(false); + }).catch(() => { + // Don't seed an empty baseline on failure: a later save would clobber + // stored data with blanks. Surface an error and block saving instead. + if (active) { + setLoadError(true); + setLoading(false); + } + }); + + return () => { + active = false; + }; + }, [channel.id]); + + const save = useCallback(async () => { + if (loadError) { + return; + } + const saved = await saveCustomState(channel.id, state); + setBaseline(saved); + }, [channel.id, state, loadError]); + + const reset = useCallback(() => { + setState(baseline); + }, [baseline]); + + // Keep the registered handlers pointing at the latest closures. + const handlersRef = useRef({save, reset}); + handlersRef.current = {save, reset}; + + useEffect(() => { + registerHandlers({ + save: () => handlersRef.current.save(), + reset: () => handlersRef.current.reset(), + }); + return () => registerHandlers(null); + }, [registerHandlers]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (loadError) { + return ( +
+ +
+ ); + } + + return ( +
+ +

+ +

+ +