From 0bdf45e35479089d0eb585598f8630e7ec8b58e3 Mon Sep 17 00:00:00 2001 From: JG Heithcock Date: Wed, 24 Jun 2026 11:39:34 -0700 Subject: [PATCH] MM-69450: Surface SAML SSO authorization failures to users When GitHub rejects API calls due to missing SAML SSO authorization, notify users via a deduplicated bot DM and show guidance in the RHS and link tooltips. --- server/githuberrors/saml.go | 51 ++++++++ server/githuberrors/saml_test.go | 62 ++++++++++ server/plugin/api.go | 45 +++++-- server/plugin/graphql/lhs_request.go | 22 ++-- server/plugin/plugin.go | 6 + server/plugin/saml_sso.go | 56 +++++++++ server/plugin/saml_sso_test.go | 110 ++++++++++++++++++ .../components/link_tooltip/link_tooltip.jsx | 50 ++++++-- webapp/src/components/sidebar_right/index.jsx | 3 +- .../sidebar_right/sidebar_right.jsx | 14 ++- webapp/src/selectors.ts | 1 + webapp/src/types/github_types.ts | 2 + 12 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 server/githuberrors/saml.go create mode 100644 server/githuberrors/saml_test.go create mode 100644 server/plugin/saml_sso.go create mode 100644 server/plugin/saml_sso_test.go diff --git a/server/githuberrors/saml.go b/server/githuberrors/saml.go new file mode 100644 index 000000000..64130abe1 --- /dev/null +++ b/server/githuberrors/saml.go @@ -0,0 +1,51 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package githuberrors + +import ( + "net/http" + "strings" + + "github.com/google/go-github/v54/github" + "github.com/pkg/errors" +) + +const samlEnforcementMessage = "organization saml enforcement" + +// IsSAMLSSORequired reports whether err indicates GitHub rejected the request +// because the OAuth token lacks SAML SSO authorization for an organization. +func IsSAMLSSORequired(err error) bool { + if err == nil { + return false + } + + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + if ssoHeader := ghErr.Response.Header.Get("X-GitHub-SSO"); ssoHeader != "" { + lower := strings.ToLower(ssoHeader) + if strings.Contains(lower, "required") || strings.Contains(lower, "partial-results") { + return true + } + } + + if ghErr.Response.StatusCode == http.StatusForbidden && containsSAMLMessage(ghErr.Message) { + return true + } + } + + return containsSAMLMessage(errorMessage(err)) +} + +func errorMessage(err error) string { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Message != "" { + return ghErr.Message + } + + return err.Error() +} + +func containsSAMLMessage(message string) bool { + return strings.Contains(strings.ToLower(message), samlEnforcementMessage) +} diff --git a/server/githuberrors/saml_test.go b/server/githuberrors/saml_test.go new file mode 100644 index 000000000..245dc596e --- /dev/null +++ b/server/githuberrors/saml_test.go @@ -0,0 +1,62 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package githuberrors + +import ( + "net/http" + "testing" + + "github.com/google/go-github/v54/github" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" +) + +func TestIsSAMLSSORequired(t *testing.T) { + t.Run("nil error", func(t *testing.T) { + assert.False(t, IsSAMLSSORequired(nil)) + }) + + t.Run("unrelated error", func(t *testing.T) { + assert.False(t, IsSAMLSSORequired(errors.New("something went wrong"))) + }) + + t.Run("403 github error with SAML message", func(t *testing.T) { + err := &github.ErrorResponse{ + Response: &http.Response{StatusCode: http.StatusForbidden}, + Message: "Resource protected by organization SAML enforcement. You must grant your personal token access to this organization.", + } + assert.True(t, IsSAMLSSORequired(err)) + }) + + t.Run("403 github error without SAML message", func(t *testing.T) { + err := &github.ErrorResponse{ + Response: &http.Response{StatusCode: http.StatusForbidden}, + Message: "Resource not accessible by integration", + } + assert.False(t, IsSAMLSSORequired(err)) + }) + + t.Run("X-GitHub-SSO required header", func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{"X-Github-Sso": {"required; url=https://github.com/orgs/acme/sso"}}, + } + err := &github.ErrorResponse{Response: resp, Message: "Forbidden"} + assert.True(t, IsSAMLSSORequired(err)) + }) + + t.Run("X-GitHub-SSO partial-results header", func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"X-Github-Sso": {"partial-results; organizations=123,456"}}, + } + err := &github.ErrorResponse{Response: resp} + assert.True(t, IsSAMLSSORequired(err)) + }) + + t.Run("graphql wrapped SAML error", func(t *testing.T) { + err := errors.Wrap(errors.New("GraphQL: Resource protected by organization SAML enforcement. You must grant your Personal Access token access to this organization."), "error in executing query") + assert.True(t, IsSAMLSSORequired(err)) + }) +} diff --git a/server/plugin/api.go b/server/plugin/api.go index a425d3b92..5be9695c7 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -84,10 +84,11 @@ type FilteredNotification struct { } type SidebarContent struct { - PRs []*graphql.GithubPRDetails `json:"prs"` - Reviews []*graphql.GithubPRDetails `json:"reviews"` - Assignments []*github.Issue `json:"assignments"` - Unreads []*FilteredNotification `json:"unreads"` + PRs []*graphql.GithubPRDetails `json:"prs"` + Reviews []*graphql.GithubPRDetails `json:"reviews"` + Assignments []*github.Issue `json:"assignments"` + Unreads []*FilteredNotification `json:"unreads"` + SAMLSSOMessage string `json:"saml_sso_message,omitempty"` } type Context struct { @@ -463,6 +464,8 @@ func (p *Plugin) completeConnectUserToGitHub(c *Context, w http.ResponseWriter, return } + p.clearSAMLSSONotification(state.UserID) + if err = p.storeGitHubToUserIDMapping(gitUser.GetLogin(), state.UserID); err != nil { c.Log.WithError(err).Warnf("Failed to store GitHub user info mapping") } @@ -815,6 +818,7 @@ func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL str wg.Go(func() { fetchedReviews, err := fetchReviews(c, client, repoOwner, repoName, prNumber) if err != nil { + p.handleGitHubAPIError(c, err) c.Log.WithError(err).Warnf("Failed to fetch reviews for PR details") return } @@ -825,6 +829,7 @@ func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL str wg.Go(func() { prInfo, _, err := client.PullRequests.Get(c.Ctx, repoOwner, repoName, prNumber) if err != nil { + p.handleGitHubAPIError(c, err) c.Log.WithError(err).Warnf("Failed to fetch PR for PR details") return } @@ -836,6 +841,7 @@ func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL str } statuses, _, err := client.Repositories.GetCombinedStatus(c.Ctx, repoOwner, repoName, prInfo.GetHead().GetSHA(), nil) if err != nil { + p.handleGitHubAPIError(c, err) c.Log.WithError(err).Warnf("Failed to fetch combined status") return } @@ -1096,31 +1102,40 @@ func (p *Plugin) createIssueComment(c *UserContext, w http.ResponseWriter, r *ht p.writeJSON(w, result) } -func (p *Plugin) getLHSData(c *UserContext) (reviewResp []*graphql.GithubPRDetails, assignmentResp []*github.Issue, openPRResp []*graphql.GithubPRDetails, err error) { +func (p *Plugin) getLHSData(c *UserContext) (reviewResp []*graphql.GithubPRDetails, assignmentResp []*github.Issue, openPRResp []*graphql.GithubPRDetails, samlSSORequired bool, err error) { graphQLClient := p.graphQLConnect(c.GHInfo) - reviewResp, assignmentResp, openPRResp, err = graphQLClient.GetLHSData(c.Ctx) + reviewResp, assignmentResp, openPRResp, samlSSORequired, err = graphQLClient.GetLHSData(c.Ctx) if err != nil { - return []*graphql.GithubPRDetails{}, []*github.Issue{}, []*graphql.GithubPRDetails{}, err + return []*graphql.GithubPRDetails{}, []*github.Issue{}, []*graphql.GithubPRDetails{}, samlSSORequired, err + } + + if samlSSORequired { + p.notifySAMLSSORequired(c.UserID) } - return reviewResp, assignmentResp, openPRResp, nil + return reviewResp, assignmentResp, openPRResp, samlSSORequired, nil } func (p *Plugin) getSidebarData(c *UserContext) (*SidebarContent, error) { - reviewResp, assignmentResp, openPRResp, err := p.getLHSData(c) + reviewResp, assignmentResp, openPRResp, samlSSORequired, err := p.getLHSData(c) if err != nil { return nil, err } p.enrichReviewsWithSLAStart(reviewResp, c.GHInfo.GitHubUsername) - return &SidebarContent{ + content := &SidebarContent{ PRs: openPRResp, Assignments: assignmentResp, Reviews: reviewResp, Unreads: p.getUnreadsData(c), - }, nil + } + if samlSSORequired { + content.SAMLSSOMessage = samlSSOUserMessage + } + + return content, nil } func (p *Plugin) getSidebarContent(c *UserContext, w http.ResponseWriter, r *http.Request) { @@ -1212,6 +1227,10 @@ func (p *Plugin) getIssueByNumber(c *UserContext, w http.ResponseWriter, r *http return } + if p.writeSAMLSSOErrorIfNeeded(c, w, cErr) { + return + } + c.Log.WithError(cErr).With(logger.LogContext{ "owner": owner, "repo": repo, @@ -1260,6 +1279,10 @@ func (p *Plugin) getPrByNumber(c *UserContext, w http.ResponseWriter, r *http.Re return } + if p.writeSAMLSSOErrorIfNeeded(c, w, cErr) { + return + } + c.Log.WithError(cErr).With(logger.LogContext{ "owner": owner, "repo": repo, diff --git a/server/plugin/graphql/lhs_request.go b/server/plugin/graphql/lhs_request.go index 2bcbb93c5..52a9ed8a5 100644 --- a/server/plugin/graphql/lhs_request.go +++ b/server/plugin/graphql/lhs_request.go @@ -10,6 +10,8 @@ import ( "github.com/google/go-github/v54/github" "github.com/pkg/errors" "github.com/shurcooL/githubv4" + + "github.com/mattermost/mattermost-plugin-github/server/githuberrors" ) const ( @@ -31,24 +33,25 @@ type GithubPRDetails struct { ReviewSLAStartAt *string `json:"review_sla_start,omitempty"` } -func (c *Client) GetLHSData(ctx context.Context) ([]*GithubPRDetails, []*github.Issue, []*GithubPRDetails, error) { +func (c *Client) GetLHSData(ctx context.Context) ([]*GithubPRDetails, []*github.Issue, []*GithubPRDetails, bool, error) { orgsList := c.getOrganizations() var resultAssignee []*github.Issue var resultReview, resultOpenPR []*GithubPRDetails + var samlSSORequired bool var err error for _, org := range orgsList { - resultReview, resultAssignee, resultOpenPR, err = c.fetchLHSData(ctx, resultReview, resultAssignee, resultOpenPR, org, c.username) + resultReview, resultAssignee, resultOpenPR, samlSSORequired, err = c.fetchLHSData(ctx, resultReview, resultAssignee, resultOpenPR, org, c.username, samlSSORequired) if err != nil { c.logger.Error("Error fetching LHS data for org", "org", org, "error", err.Error()) } } if len(orgsList) == 0 { - return c.fetchLHSData(ctx, resultReview, resultAssignee, resultOpenPR, "", c.username) + return c.fetchLHSData(ctx, resultReview, resultAssignee, resultOpenPR, "", c.username, samlSSORequired) } - return resultReview, resultAssignee, resultOpenPR, nil + return resultReview, resultAssignee, resultOpenPR, samlSSORequired, nil } func (c *Client) fetchLHSData( @@ -58,7 +61,8 @@ func (c *Client) fetchLHSData( resultOpenPR []*GithubPRDetails, org string, username string, -) ([]*GithubPRDetails, []*github.Issue, []*GithubPRDetails, error) { + samlSSORequired bool, +) ([]*GithubPRDetails, []*github.Issue, []*GithubPRDetails, bool, error) { baseOpenPR := fmt.Sprintf("author:%s is:pr is:%s archived:false", username, githubv4.PullRequestStateOpen) baseReviewPR := fmt.Sprintf("review-requested:%s is:pr is:%s archived:false", username, githubv4.PullRequestStateOpen) baseAssignee := fmt.Sprintf("assignee:%s is:%s archived:false", username, githubv4.PullRequestStateOpen) @@ -82,7 +86,11 @@ func (c *Client) fetchLHSData( for !allReviewRequestsFetched || !allAssignmentsFetched || !allOpenPRsFetched { if err := c.executeQuery(ctx, &mainQuery, params); err != nil { - return nil, nil, nil, errors.Wrap(err, "Not able to execute the query") + if githuberrors.IsSAMLSSORequired(err) { + samlSSORequired = true + return resultReview, resultAssignee, resultOpenPR, samlSSORequired, nil + } + return nil, nil, nil, samlSSORequired, errors.Wrap(err, "Not able to execute the query") } if !allReviewRequestsFetched { @@ -128,7 +136,7 @@ func (c *Client) fetchLHSData( } } - return resultReview, resultAssignee, resultOpenPR, nil + return resultReview, resultAssignee, resultOpenPR, samlSSORequired, nil } func getPR(prResp *prSearchNodes) *GithubPRDetails { diff --git a/server/plugin/plugin.go b/server/plugin/plugin.go index 96a723da5..2599c7fa0 100644 --- a/server/plugin/plugin.go +++ b/server/plugin/plugin.go @@ -726,6 +726,8 @@ func (p *Plugin) getGitHubToUsernameMapping(githubUsername string) string { } func (p *Plugin) disconnectGitHubAccount(userID string) { + p.clearSAMLSSONotification(userID) + userInfo, apiErr := p.getGitHubUserInfo(userID) if apiErr != nil { if apiErr.ID == apiErrorIDNotConnected { @@ -1353,6 +1355,10 @@ func (p *Plugin) useGitHubClient(info *GitHubUserInfo, toRun func(info *GitHubUs p.handleRevokedToken(info) } + if err != nil { + p.handleGitHubAPIError(&UserContext{Context: Context{UserID: info.UserID}, GHInfo: info}, err) + } + return err } diff --git a/server/plugin/saml_sso.go b/server/plugin/saml_sso.go new file mode 100644 index 000000000..44e3e9259 --- /dev/null +++ b/server/plugin/saml_sso.go @@ -0,0 +1,56 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package plugin + +import ( + "net/http" + + "github.com/mattermost/mattermost-plugin-github/server/githuberrors" +) + +const ( + apiErrorIDSAMLSSORequired = "saml_sso_required" + samlSSONotifiedKey = "_samlSSONotified" + + samlSSOUserMessage = "GitHub is rejecting API requests because SAML SSO authorization is required for one or more organizations. Run `/github disconnect` and then `/github connect` again. When prompted on GitHub, authorize access for your organizations." +) + +func (p *Plugin) notifySAMLSSORequired(userID string) { + key := userID + samlSSONotifiedKey + var notified bool + if err := p.store.Get(key, ¬ified); err == nil && notified { + return + } + + p.CreateBotDMPost(userID, samlSSOUserMessage, "custom_git_saml_sso") + if _, err := p.store.Set(key, true); err != nil { + p.client.Log.Warn("Failed to store SAML SSO notification state", "userID", userID, "error", err.Error()) + } +} + +func (p *Plugin) clearSAMLSSONotification(userID string) { + if err := p.store.Delete(userID + samlSSONotifiedKey); err != nil { + p.client.Log.Debug("Failed to clear SAML SSO notification state", "userID", userID, "error", err.Error()) + } +} + +func (p *Plugin) writeSAMLSSOErrorIfNeeded(c *UserContext, w http.ResponseWriter, err error) bool { + if !githuberrors.IsSAMLSSORequired(err) { + return false + } + + p.notifySAMLSSORequired(c.UserID) + p.writeAPIError(w, &APIErrorResponse{ + ID: apiErrorIDSAMLSSORequired, + Message: samlSSOUserMessage, + StatusCode: http.StatusForbidden, + }) + return true +} + +func (p *Plugin) handleGitHubAPIError(c *UserContext, err error) { + if githuberrors.IsSAMLSSORequired(err) { + p.notifySAMLSSORequired(c.UserID) + } +} diff --git a/server/plugin/saml_sso_test.go b/server/plugin/saml_sso_test.go new file mode 100644 index 000000000..09e7f7723 --- /dev/null +++ b/server/plugin/saml_sso_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2018-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package plugin + +import ( + "net/http" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/go-github/v54/github" + "github.com/pkg/errors" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-github/server/mocks" +) + +func setupSAMLSSOTest(t *testing.T) (*Plugin, *plugintest.API, *mocks.MockKvStore, *gomock.Controller) { + t.Helper() + ctrl := gomock.NewController(t) + mockKvStore := mocks.NewMockKvStore(ctrl) + + api := &plugintest.API{} + p := NewPlugin() + p.store = mockKvStore + p.BotUserID = MockBotID + p.SetAPI(api) + p.client = pluginapi.NewClient(api, p.Driver) + + return p, api, mockKvStore, ctrl +} + +func TestNotifySAMLSSORequired_SendsDMOnce(t *testing.T) { + p, api, mockKvStore, ctrl := setupSAMLSSOTest(t) + defer ctrl.Finish() + + userID := "user1" + key := userID + samlSSONotifiedKey + + mockKvStore.EXPECT().Get(key, gomock.Any()).Return(nil) + mockKvStore.EXPECT().Set(key, true).Return(true, nil) + + api.On("GetDirectChannel", userID, MockBotID).Return(&model.Channel{Id: "dm-channel"}, nil) + api.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool { + return post.ChannelId == "dm-channel" && post.Message == samlSSOUserMessage && post.Type == "custom_git_saml_sso" + })).Return(&model.Post{}, nil) + + p.notifySAMLSSORequired(userID) + + mockKvStore.EXPECT().Get(key, gomock.Any()).DoAndReturn(func(_ string, out any) error { + ptr, ok := out.(*bool) + require.True(t, ok) + *ptr = true + return nil + }) + + p.notifySAMLSSORequired(userID) + + api.AssertExpectations(t) +} + +func TestWriteSAMLSSOErrorIfNeeded(t *testing.T) { + p, api, mockKvStore, ctrl := setupSAMLSSOTest(t) + defer ctrl.Finish() + + userID := "user1" + key := userID + samlSSONotifiedKey + c := &UserContext{Context: Context{UserID: userID}} + + mockKvStore.EXPECT().Get(key, gomock.Any()).Return(nil) + mockKvStore.EXPECT().Set(key, true).Return(true, nil) + api.On("GetDirectChannel", userID, MockBotID).Return(&model.Channel{Id: "dm-channel"}, nil) + api.On("CreatePost", mock.Anything).Return(&model.Post{}, nil) + + recorder := &responseRecorder{} + samlErr := &github.ErrorResponse{ + Response: &http.Response{StatusCode: http.StatusForbidden}, + Message: "Resource protected by organization SAML enforcement. You must grant your personal token access to this organization.", + } + + require.True(t, p.writeSAMLSSOErrorIfNeeded(c, recorder, samlErr)) + require.Equal(t, http.StatusForbidden, recorder.statusCode) + require.Contains(t, recorder.body, apiErrorIDSAMLSSORequired) + require.Contains(t, recorder.body, samlSSOUserMessage) + + require.False(t, p.writeSAMLSSOErrorIfNeeded(c, recorder, errors.New("unrelated error"))) +} + +type responseRecorder struct { + statusCode int + body string +} + +func (r *responseRecorder) Header() http.Header { + return http.Header{} +} + +func (r *responseRecorder) Write(body []byte) (int, error) { + r.body += string(body) + return len(body), nil +} + +func (r *responseRecorder) WriteHeader(statusCode int) { + r.statusCode = statusCode +} diff --git a/webapp/src/components/link_tooltip/link_tooltip.jsx b/webapp/src/components/link_tooltip/link_tooltip.jsx index 5134e10c1..eddaa0164 100644 --- a/webapp/src/components/link_tooltip/link_tooltip.jsx +++ b/webapp/src/components/link_tooltip/link_tooltip.jsx @@ -15,6 +15,7 @@ const maxTicketDescriptionLength = 160; export const LinkTooltip = ({href, connected, show, theme, enterpriseURL}) => { const [data, setData] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); useEffect(() => { const initData = async () => { let owner; @@ -36,13 +37,26 @@ export const LinkTooltip = ({href, connected, show, theme, enterpriseURL}) => { } let res; - switch (type) { - case 'issues': - res = await Client.getIssue(owner, repo, number); - break; - case 'pull': - res = await Client.getPullRequest(owner, repo, number); - break; + try { + switch (type) { + case 'issues': + res = await Client.getIssue(owner, repo, number); + break; + case 'pull': + res = await Client.getPullRequest(owner, repo, number); + break; + } + } catch (e) { + const message = e?.message || ''; + if (message.includes('saml_sso_required') || message.toLowerCase().includes('saml')) { + try { + const parsed = JSON.parse(message); + setErrorMessage(parsed.message || message); + } catch { + setErrorMessage(message); + } + } + return; } if (res) { res.owner = owner; @@ -53,12 +67,12 @@ export const LinkTooltip = ({href, connected, show, theme, enterpriseURL}) => { }; // show is not provided for Mattermost Server < 5.28 - if (!connected || data || ((typeof (show) !== 'undefined' || show != null) && !show)) { + if (!connected || data || errorMessage || ((typeof (show) !== 'undefined' || show != null) && !show)) { return; } initData(); - }, [connected, data, href, show, enterpriseURL]); + }, [connected, data, errorMessage, href, show, enterpriseURL]); const openedByLink = useMemo(() => { if (!data?.user?.login) { @@ -120,6 +134,24 @@ export const LinkTooltip = ({href, connected, show, theme, enterpriseURL}) => { ); }; + if (errorMessage) { + return ( +
+
+

+ {errorMessage} +

+
+
+ ); + } + if (data) { let date = new Date(data.created_at); date = date.toDateString(); diff --git a/webapp/src/components/sidebar_right/index.jsx b/webapp/src/components/sidebar_right/index.jsx index 3e62e1c69..91121ff41 100644 --- a/webapp/src/components/sidebar_right/index.jsx +++ b/webapp/src/components/sidebar_right/index.jsx @@ -11,7 +11,7 @@ import {getSidebarData} from 'src/selectors'; import SidebarRight from './sidebar_right.jsx'; function mapStateToProps(state) { - const {username, reviews, yourPrs, yourAssignments, unreads, enterpriseURL, orgs, rhsState, reviewTargetDays} = getSidebarData(state); + const {username, reviews, yourPrs, yourAssignments, unreads, enterpriseURL, orgs, rhsState, reviewTargetDays, samlSSOMessage} = getSidebarData(state); return { username, reviews, @@ -22,6 +22,7 @@ function mapStateToProps(state) { orgs, rhsState, reviewTargetDays, + samlSSOMessage, }; } diff --git a/webapp/src/components/sidebar_right/sidebar_right.jsx b/webapp/src/components/sidebar_right/sidebar_right.jsx index 567527929..06941fbce 100644 --- a/webapp/src/components/sidebar_right/sidebar_right.jsx +++ b/webapp/src/components/sidebar_right/sidebar_right.jsx @@ -74,6 +74,7 @@ export default class SidebarRight extends React.PureComponent { yourAssignments: PropTypes.arrayOf(PropTypes.object), rhsState: PropTypes.string, reviewTargetDays: PropTypes.number, + samlSSOMessage: PropTypes.string, theme: PropTypes.object.isRequired, actions: PropTypes.shape({ getYourPrsDetails: PropTypes.func.isRequired, @@ -108,7 +109,7 @@ export default class SidebarRight extends React.PureComponent { orgQuery += ('+org%3A' + org); return orgQuery; }); - const {yourPrs, reviews, unreads, yourAssignments, username, rhsState} = this.props; + const {yourPrs, reviews, unreads, yourAssignments, username, rhsState, samlSSOMessage} = this.props; let title = ''; let githubItems = []; @@ -164,6 +165,14 @@ export default class SidebarRight extends React.PureComponent { >{title} + {samlSSOMessage && ( +
+ {samlSSOMessage} +
+ )}