Skip to content

Commit

Permalink
[MM-58290] Add UI to select notification preference. (#657)
Browse files Browse the repository at this point in the history
* add connection status to the store

* change event name

* Add tests for connection status

* add tests for disconnect command

* move user settings to it's own file

* typo

* readd connect button
  • Loading branch information
JulienTant authored May 21, 2024
1 parent 0c1d5d0 commit 3533149
Show file tree
Hide file tree
Showing 17 changed files with 380 additions and 82 deletions.
102 changes: 83 additions & 19 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const (
QueryParamPrimaryPlatform = "primary_platform"
QueryParamChannelID = "channel_id"
QueryParamPostID = "post_id"
QueryParamFromPreferences = "from_preferences"

APIChoosePrimaryPlatform = "/choose-primary-platform"
)
Expand All @@ -78,6 +79,7 @@ func NewAPI(p *Plugin, store store.Store) *API {
router.HandleFunc("/lifecycle", api.processLifecycle).Methods("POST")
router.HandleFunc("/autocomplete/teams", api.autocompleteTeams).Methods("GET")
router.HandleFunc("/autocomplete/channels", api.autocompleteChannels).Methods("GET")
router.HandleFunc("/connection-status", api.connectionStatus).Methods("GET")
router.HandleFunc("/connect", api.connect).Methods("GET", "OPTIONS")
router.HandleFunc("/oauth-redirect", api.oauthRedirectHandler).Methods("GET", "OPTIONS")
router.HandleFunc("/connected-users", api.getConnectedUsers).Methods(http.MethodGet)
Expand Down Expand Up @@ -348,10 +350,16 @@ func (a *API) connect(w http.ResponseWriter, r *http.Request) {
userID = a.p.GetBotUserID()
}

stateSuffix := ""
fromPreferences := query.Get(QueryParamFromPreferences)
channelID := query.Get(QueryParamChannelID)
postID := query.Get(QueryParamPostID)
if channelID == "" || postID == "" {
a.p.API.LogWarn("Missing channelID or postID from query parameters", "channel_id", channelID, "post_id", postID)
if fromPreferences == "true" {
stateSuffix = "fromPreferences:true"
} else if channelID != "" && postID != "" {
stateSuffix = fmt.Sprintf("fromBotMessage:%s|%s", channelID, postID)
} else {
a.p.API.LogWarn("could not determine origin of the connect request", "channel_id", channelID, "post_id", postID, "from_preferences", fromPreferences)
http.Error(w, "Missing required query parameters.", http.StatusBadRequest)
return
}
Expand All @@ -362,7 +370,7 @@ func (a *API) connect(w http.ResponseWriter, r *http.Request) {
return
}

state := fmt.Sprintf("%s_%s_%s_%s", model.NewId(), userID, postID, channelID)
state := fmt.Sprintf("%s_%s_%s", model.NewId(), userID, stateSuffix)
if err := a.store.StoreOAuth2State(state); err != nil {
a.p.API.LogWarn("Error in storing the OAuth state", "error", err.Error())
http.Error(w, "Error in trying to connect the account, please try again.", http.StatusInternalServerError)
Expand All @@ -381,6 +389,21 @@ func (a *API) connect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, connectURL, http.StatusSeeOther)
}

func (a *API) connectionStatus(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-ID")

response := map[string]bool{"connected": false}
if storedToken, _ := a.p.store.GetTokenForMattermostUser(userID); storedToken != nil {
response["connected"] = true
}

w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
a.p.API.LogWarn("Error while writing response", "error", err.Error())
w.WriteHeader(http.StatusInternalServerError)
}
}

func (a *API) primaryPlatform(w http.ResponseWriter, r *http.Request) {
bundlePath, err := a.p.API.GetBundlePath()
if err != nil {
Expand Down Expand Up @@ -448,7 +471,36 @@ func (a *API) oauthRedirectHandler(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")

stateArr := strings.Split(state, "_")
if len(stateArr) != 4 {
if len(stateArr) != 3 {
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}

// determine origin of the connect request
// if the state is fromPreferences, the user is connecting from the preferences page
// if the state is fromBotMessage, the user is connecting from a bot message
originInfo := strings.Split(stateArr[2], ":")
if len(originInfo) != 2 {
a.p.API.LogWarn("unable to get origin info from state", "state", stateArr[2])
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}

channelID := ""
postID := ""
switch originInfo[0] {
case "fromPreferences":
// do nothing
case "fromBotMessage":
fromBotMessageArgs := strings.Split(originInfo[1], "|")
if len(fromBotMessageArgs) != 2 {
a.p.API.LogWarn("unable to get args from bot message", "origin_info", originInfo[1])
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
channelID = fromBotMessageArgs[0]
postID = fromBotMessageArgs[1]
default:
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
Expand Down Expand Up @@ -555,6 +607,10 @@ func (a *API) oauthRedirectHandler(w http.ResponseWriter, r *http.Request) {
return
}

a.p.API.PublishWebSocketEvent(WSEventUserConnected, map[string]any{}, &model.WebsocketBroadcast{
UserId: mmUserID,
})

if err = a.p.store.DeleteUserInvite(mmUserID); err != nil {
a.p.API.LogWarn("Unable to clear user invite", "user_id", mmUserID, "error", err.Error())
}
Expand All @@ -573,22 +629,30 @@ func (a *API) oauthRedirectHandler(w http.ResponseWriter, r *http.Request) {
_, _ = a.p.updateAutomutingOnUserConnect(mmUserID)

const userConnectedMessage = "Welcome to Mattermost for Microsoft Teams! Your conversations with MS Teams users are now synchronized."
post := &model.Post{
Id: stateArr[2],
Message: userConnectedMessage,
ChannelId: stateArr[3],
UserId: a.p.GetBotUserID(),
CreateAt: model.GetMillis(),
}

_, appErr = a.p.GetAPI().GetPost(stateArr[2])
if appErr == nil {
_, appErr = a.p.GetAPI().UpdatePost(post)
if appErr != nil {
a.p.API.LogWarn("Unable to update post", "post", post.Id, "error", err.Error())
switch originInfo[0] {
case "fromBotMessage":
post := &model.Post{
Id: postID,
Message: userConnectedMessage,
ChannelId: channelID,
UserId: a.p.GetBotUserID(),
CreateAt: model.GetMillis(),
}

_, appErr = a.p.GetAPI().GetPost(post.Id)
if appErr == nil {
_, appErr = a.p.GetAPI().UpdatePost(post)
if appErr != nil {
a.p.API.LogWarn("Unable to update post", "post", post.Id, "error", err.Error())
}
} else {
_ = a.p.GetAPI().UpdateEphemeralPost(mmUser.Id, post)
}
case "fromPreferences":
err = a.p.botSendDirectMessage(mmUserID, userConnectedMessage)
if err != nil {
a.p.API.LogWarn("Unable to send welcome direct message to user from preference", "error", err.Error())
}
} else {
_ = a.p.GetAPI().UpdateEphemeralPost(mmUser.Id, post)
}

connectURL := a.p.GetURL() + "/primary-platform"
Expand Down
55 changes: 55 additions & 0 deletions server/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1065,3 +1065,58 @@ func TestIFrameMattermostTab(t *testing.T) {
func TestIFrameManifest(t *testing.T) {
t.Skip()
}

func TestConnectionStatus(t *testing.T) {
th := setupTestHelper(t)
apiURL := th.pluginURL(t, "/connection-status")
team := th.SetupTeam(t)

sendRequest := func(t *testing.T, user *model.User) (connected bool) {
t.Helper()
client := th.SetupClient(t, user.Id)

request, err := http.NewRequest(http.MethodGet, apiURL, nil)
require.NoError(t, err)

request.Header.Set(model.HeaderAuth, client.AuthType+" "+client.AuthToken)

response, err := http.DefaultClient.Do(request)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, response.Body.Close())
})

resMap := map[string]bool{}
err = json.NewDecoder(response.Body).Decode(&resMap)
require.NoError(t, err)

return resMap["connected"]
}

t.Run("connected users should get true", func(t *testing.T) {
th.Reset(t)
user := th.SetupUser(t, team)
th.ConnectUser(t, user.Id)

connected := sendRequest(t, user)
assert.True(t, connected)
})

t.Run("never connected users should get false", func(t *testing.T) {
th.Reset(t)
user := th.SetupUser(t, team)

connected := sendRequest(t, user)
assert.False(t, connected)
})

t.Run("disconnected users should get false", func(t *testing.T) {
th.Reset(t)
user := th.SetupUser(t, team)
th.ConnectUser(t, user.Id)
th.DisconnectUser(t, user.Id)

connected := sendRequest(t, user)
assert.False(t, connected)
})
}
4 changes: 4 additions & 0 deletions server/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,10 @@ func (p *Plugin) executeDisconnectCommand(args *model.CommandArgs) (*model.Comma
return p.cmdSuccess(args, fmt.Sprintf("Error: unable to reset your primary platform, %s", err.Error()))
}

p.API.PublishWebSocketEvent(WSEventUserDisconnected, map[string]any{}, &model.WebsocketBroadcast{
UserId: args.UserId,
})

_, _ = p.updateAutomutingOnUserDisconnect(args.UserId)

return p.cmdSuccess(args, "Your account has been disconnected.")
Expand Down
6 changes: 6 additions & 0 deletions server/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ func assertNoCommandResponse(t *testing.T, actual *model.CommandResponse) {
require.Equal(t, &model.CommandResponse{}, actual)
}

func assertWebsocketEvent(th *testHelper, t *testing.T, userID string, eventType string) {
t.Helper()
th.assertWebsocketEvent(t, userID, eventType)
}

func assertEphemeralResponse(th *testHelper, t *testing.T, args *model.CommandArgs, message string) {
t.Helper()
th.assertEphemeralMessage(t, args.UserId, args.ChannelId, message)
Expand Down Expand Up @@ -452,6 +457,7 @@ func TestExecuteDisconnectCommand(t *testing.T) {
commandResponse, appErr := th.p.executeDisconnectCommand(args)
require.Nil(t, appErr)
assertNoCommandResponse(t, commandResponse)
assertWebsocketEvent(th, t, user1.Id, makePluginWebsocketEventName(WSEventUserDisconnected))
assertEphemeralResponse(th, t, args, "Your account has been disconnected.")

require.Equal(t, storemodels.PreferenceValuePlatformMM, th.p.getPrimaryPlatform(user1.Id))
Expand Down
25 changes: 25 additions & 0 deletions server/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,31 @@ func (th *testHelper) GetWebsocketClientForUser(t *testing.T, userID string) *mo
return websocketClient
}

func makePluginWebsocketEventName(short string) string {
return fmt.Sprintf("custom_%s_%s", manifest.Id, short)
}

func (th *testHelper) assertWebsocketEvent(t *testing.T, userID, eventType string) {
t.Helper()

websocketClient := th.GetWebsocketClientForUser(t, userID)

for {
select {
case event, ok := <-websocketClient.EventChannel:
if !ok {
t.Fatal("channel closed before getting websocket event")
}

if event.EventType() == model.WebsocketEventType(eventType) {
return
}
case <-time.After(5 * time.Second):
t.Fatal("failed to get websocket event " + eventType)
}
}
}

func (th *testHelper) assertEphemeralMessage(t *testing.T, userID, channelID, message string) {
t.Helper()

Expand Down
6 changes: 6 additions & 0 deletions server/websocket_events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package main

const (
WSEventUserConnected = "user_connected"
WSEventUserDisconnected = "user_disconnected"
)
9 changes: 9 additions & 0 deletions webapp/src/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Actions from './types/actions';

export const userConnected = (): Actions.UserHasConnected => ({
type: Actions.USER_CONNECTED,
});

export const userDisconnected = (): Actions.UserHasDisconnected => ({
type: Actions.USER_DISCONNECTED,
});
12 changes: 12 additions & 0 deletions webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export interface SiteStats {
total_users_sending: number;
}

export interface ConnectionStatus {
connected: boolean;
}

class ClientClass {
url = '';

Expand All @@ -35,6 +39,14 @@ class ClientClass {
return data as SiteStats;
};

connectionStatus = async (): Promise<ConnectionStatus> => {
const data = await this.doGet(`${this.url}/connection-status`);
if (!data) {
return {connected: false};
}
return data as ConnectionStatus;
};

doGet = async (url: string, headers: {[key: string]: any} = {}) => {
headers['X-Timezone-Offset'] = new Date().getTimezoneOffset();

Expand Down
Loading

0 comments on commit 3533149

Please sign in to comment.