Webapp - Outgoing OAuth Connections (#25507)

* added store

* make generated

* add missing license headers

* fix receiver name

* i18n

* i18n sorting

* update migrations from master

* make migrations-extract

* update retrylayer tests

* replaced sql query with id pagination

* fixed flaky tests

* missing columns

* missing columns on save/update

* typo

* improved tests

* remove enum from mysql colum

* add password credentials to store

* license changes

* OAuthOutgoingConnectionInterface

* Oauth -> OAuth

* make generated

* copied over installed_oauth_apps component and renamed things to installed_outgoing_oauth_connections

* merge migrations

* renamed migrations

* model change suggestions

* refactor test functionsn

* migration typo

* refactor store table names

* updated sanitize test

* cleanup merge

* refactor symbol

* "installed outgoing oauth connections" page works

* move things into a nested folder

* add and edit page stubs work

* list endpoint

* oauthoutgoingconnection -> outgoingoauthconnection

* signature change

* i18n update

* granttype typo

* naming

* api list

* uppercase typo

* i18n

* missing license header

* fixed path in comments

* updated openapi definitions

* changes to support selecting command request url

* sanitize connections

* make generated

* test license and no feature flag

* removed t.fatal

* updated testhelper calls

* yaml schema fixes

* switched interface name

* suggested translation

* missing i18n translation

* management permission

* moved permission initalization to proper place

* endpoints

* put tests

* error check typo

* fixed specific enttity urls

* tests

* read permission check

* updated openapi definitions

* i18n

* GetConnectionByAudience method

* notes

* replaced GetConnectionsByAudience with a filter

* added custom oauth token object

* updated interface and usage

* properly set enterprise interface

* move retrieval logic to impl

* webhook tests

* translations

* i18n: updates

* address comments

* endpoint and tests

* i18n

* api docs

* fixed endpoint path

* sq.like

* use filter object instead of parameters

* set url values if not empty

* typos

* converted some components to function components, and move around files

* correctly check token url

* restore flag to previous value

* added command oauth handler

* update enterprise imports

* migrate last component to function component

* Added enterprise import

* refactor permissions and add necessary webapp code

* Check correct flag in permission tree

* allow partial updates

* sort i18n webapp

* missing test modification

* fixed webapp i18n sorting

* allow validating stored connections

* added missing translation

* fix finished adding connection link and text on result page

* added missing permission to smoke tests

* missing role in smoke test

* updated translations

* updated translations

* support editing client secret on existing connection

* fix some i18n strings

* updated translations

* better error messages

* progress on using react select for command request url while maintaining typed in value

* remove writeheader, test

* HasValidGrantType

* end early to avoid nil pointer errors

* move slash command request url input box into its own component

* wrap components related to oauth connections in config check

* fix tests

* i18n-extract

* change some i18n strings to say "Outgoing OAuth 2.0 Connections"

* remove debug code

* fixed i18n

* updated i18n file

* feature configuration backend

* typo

* add system console setting

* Revert "typo"

This reverts commit 669da23e8e.

* Revert "updated i18n file"

This reverts commit d0882c0dd7.

* Revert "fixed i18n"

This reverts commit 3108866bc1.

* fixed i18n

* updated i18n file

* typo

* updated i18n

* updated i18n

* updated i18n

* updated version to 9.6

* replace feature flag with system console configuration

* i18n

* updated tests

* pr feedback

* fix styling of disabled text box

* fix styling of action links in integration console

* server changes for validation feature

* webapp changes for validation feature

* pencil icon styling

* styling fixes for oauth audience correct configuration message

* fix sanitize test

* remove max lengths from outgoing oauth connection form

* use config var in webapp instead of feature flag

* change asterisks to bullets

* update api docs for validate endpoint

* feedback from ux review

* fix lint, types, tests

* fix stylelint

* implement validation button under the token url input

* support wildcard for matching audience urls

* updates for styling

* update snapshots

* add doc links for the outgoing oauth connections feature

* change doc links to use permalink

* add docs link to system console

* fix: use limitedreader in json decoding

* fix: form error in validation

* management permission can read now

* updated api documentation

* doc typo

* require one permission to read only

* fix api connection list audience filter

* fix audience matching and add loading indicator

* fix team permissions on outgoing oauth connection api calls

* fix api doc and test, for adding team id to query params

* handle read permissions by adding a team in the payload

* missing teamid query parameter in test

* change validate button logic to not require audience urls to be filled out

* fix redux type

---------

Co-authored-by: Felipe Martin <me@fmartingr.com>
This commit is contained in:
Michael Kochell 2024-02-09 14:49:49 -05:00 committed by GitHub
parent 3f6c94cfc3
commit 4e071e861c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 10116 additions and 201 deletions

View File

@ -53,6 +53,12 @@ components:
application/json:
schema:
$ref: "#/components/schemas/AppError"
BadGateway:
description: Bad gateway
content:
application/json:
schema:
$ref: "#/components/schemas/AppError"
schemas:
User:
type: object
@ -3604,6 +3610,33 @@ components:
audiences:
description: The audiences of the outgoing OAuth connection.
type: string
OutgoingOAuthConnectionPostItem:
type: object
properties:
name:
description: The name of the outgoing OAuth connection.
type: string
client_id:
description: The client ID of the outgoing OAuth connection.
type: string
client_secret:
description: The client secret of the outgoing OAuth connection.
type: string
credentials_username:
description: The username of the credentials of the outgoing OAuth connection.
type: string
credentials_password:
description: The password of the credentials of the outgoing OAuth connection.
type: string
oauth_token_url:
description: The OAuth token URL of the outgoing OAuth connection.
type: string
grant_type:
description: The grant type of the outgoing OAuth connection.
type: string
audiences:
description: The audiences of the outgoing OAuth connection.
type: string
externalDocs:
description: Find out more about Mattermost
url: 'https://about.mattermost.com'

View File

@ -8,8 +8,15 @@
description: >
List all outgoing OAuth connections.
__Minimum server version__: 9.5
__Minimum server version__: 9.6
operationId: ListOutgoingOAuthConnections
parameters:
- name: team_id
in: query
description: Current Team ID in integrations backstage
required: true
schema:
type: string
responses:
"200":
description: Successfully fetched outgoing OAuth connections
@ -25,6 +32,45 @@
$ref: "#/components/responses/InternalServerError"
"501":
$ref: "#/components/responses/NotImplemented"
post:
tags:
- oauth
- outgoing_connections
- outgoing_oauth_connections
summary: Create a connection
description: >
Create an outgoing OAuth connection.
__Minimum server version__: 9.6
operationId: CreateOutgoingOAuthConnection
parameters:
- name: team_id
in: query
description: Current Team ID in integrations backstage
required: true
schema:
type: string
requestBody:
description: Outgoing OAuth connection to create
content:
application/json:
schema:
$ref: "#/components/schemas/OutgoingOAuthConnectionPostItem"
responses:
"201":
description: Successfully created outgoing OAuth connection
content:
application/json:
schema:
$ref: "#/components/schemas/OutgoingOAuthConnectionGetItem"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"
"501":
$ref: "#/components/responses/NotImplemented"
/api/v4/oauth/outgoing_connections/{connection_id}:
get:
tags:
@ -35,8 +81,15 @@
description: >
Retrieve an outgoing OAuth connection.
__Minimum server version__: 9.5
__Minimum server version__: 9.6
operationId: GetOutgoingOAuthConnection
parameters:
- name: team_id
in: query
description: Current Team ID in integrations backstage
required: true
schema:
type: string
responses:
"200":
description: Successfully fetched outgoing OAuth connection
@ -50,3 +103,115 @@
$ref: "#/components/responses/InternalServerError"
"501":
$ref: "#/components/responses/NotImplemented"
put:
tags:
- oauth
- outgoing_connections
- outgoing_oauth_connections
summary: Update a connection
description: >
Update an outgoing OAuth connection.
__Minimum server version__: 9.6
operationId: UpdateOutgoingOAuthConnection
parameters:
- name: team_id
in: query
description: Current Team ID in integrations backstage
required: true
schema:
type: string
requestBody:
description: Outgoing OAuth connection to update
content:
application/json:
schema:
$ref: "#/components/schemas/OutgoingOAuthConnectionPostItem"
responses:
"200":
description: Successfully updated outgoing OAuth connection
content:
application/json:
schema:
$ref: "#/components/schemas/OutgoingOAuthConnectionGetItem"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalServerError"
"501":
$ref: "#/components/responses/NotImplemented"
delete:
tags:
- oauth
- outgoing_connections
- outgoing_oauth_connections
summary: Delete a connection
description: >
Delete an outgoing OAuth connection.
__Minimum server version__: 9.6
operationId: DeleteOutgoingOAuthConnection
parameters:
- name: team_id
in: query
description: Current Team ID in integrations backstage
required: true
schema:
type: string
responses:
"200":
description: Successfully deleted outgoing OAuth connection
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalServerError"
"501":
$ref: "#/components/responses/NotImplemented"
/api/v4/oauth/outgoing_connections/validate:
post:
tags:
- oauth
- outgoing_connections
- outgoing_oauth_connections
summary: Validate a connection configuration
description: >
Validate an outgoing OAuth connection. If an id is provided in the payload, and no client secret is provided, then the stored client secret is implicitly used for the validation.
__Minimum server version__: 9.6
operationId: ValidateOutgoingOAuthConnection
parameters:
- name: team_id
in: query
description: Current Team ID in integrations backstage
required: true
schema:
type: string
requestBody:
description: Outgoing OAuth connection to validate
content:
application/json:
schema:
$ref: "#/components/schemas/OutgoingOAuthConnectionPostItem"
responses:
"200":
description: The connection configuration is valid.
"400":
description: The connection configuration is invalid.
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
"500":
$ref: "#/components/responses/InternalServerError"
"501":
$ref: "#/components/responses/NotImplemented"
"502":
description: The connection configuration may be valid, but the server is unable to validate it upstream.
$ref: "#/components/responses/BadGateway"

File diff suppressed because one or more lines are too long

View File

@ -277,7 +277,7 @@ func Init(srv *app.Server) (*API, error) {
api.BaseRoutes.Limits = api.BaseRoutes.APIRoot.PathPrefix("/limits").Subrouter()
api.BaseRoutes.OutgoingOAuthConnections = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections").Subrouter()
api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.OutgoingOAuthConnections.PathPrefix("/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter()
api.InitUser()
api.InitBot()

View File

@ -6,11 +6,14 @@ package api4
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"github.com/mattermost/logr/v2"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/audit"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
@ -20,12 +23,44 @@ const (
func (api *API) InitOutgoingOAuthConnection() {
api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(listOutgoingOAuthConnections)).Methods("GET")
api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(createOutgoingOAuthConnection)).Methods("POST")
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(getOutgoingOAuthConnection)).Methods("GET")
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(updateOutgoingOAuthConnection)).Methods("PUT")
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(deleteOutgoingOAuthConnection)).Methods("DELETE")
api.BaseRoutes.OutgoingOAuthConnections.Handle("/validate", api.APISessionRequired(validateOutgoingOAuthConnectionCredentials)).Methods("POST")
}
// checkOutgoingOAuthConnectionReadPermissions checks if the user has the permissions to read outgoing oauth connections.
// An user with the permissions to manage outgoing oauth connections can read outgoing oauth connections.
// Otherwise the user needs to have the permissions to manage outgoing webhooks or slash commands in order to read outgoing
// oauth connections so that they can use them.
// This is made in this way so only users with the management permission can setup the outgoing oauth connections and then
// other users can use them in their outgoing webhooks and slash commands if they have permissions to manage those.
func checkOutgoingOAuthConnectionReadPermissions(c *Context, teamId string) bool {
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOutgoingOAuthConnections) ||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOutgoingWebhooks) ||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageSlashCommands) {
return true
}
c.SetPermissionError(model.PermissionManageOutgoingWebhooks, model.PermissionManageSlashCommands)
return false
}
// checkOutgoingOAuthConnectionWritePermissions checks if the user has the permissions to write outgoing oauth connections.
// This is a more granular permissions intended for system admins to manage (setup) outgoing oauth connections.
func checkOutgoingOAuthConnectionWritePermissions(c *Context) bool {
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOutgoingOAuthConnections) {
return true
}
c.SetPermissionError(model.PermissionManageOutgoingOAuthConnections)
return false
}
func ensureOutgoingOAuthConnectionInterface(c *Context, where string) (einterfaces.OutgoingOAuthConnectionInterface, bool) {
if !c.App.Config().FeatureFlags.OutgoingOAuthConnections {
c.Err = model.NewAppError(where, "api.context.outgoing_oauth_connection.not_available.feature_flag", nil, "", http.StatusNotImplemented)
if c.App.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && !*c.App.Config().ServiceSettings.EnableOutgoingOAuthConnections {
c.Err = model.NewAppError(where, "api.context.outgoing_oauth_connection.not_available.configuration_disabled", nil, "", http.StatusNotImplemented)
return nil, false
}
@ -37,8 +72,9 @@ func ensureOutgoingOAuthConnectionInterface(c *Context, where string) (einterfac
}
type listOutgoingOAuthConnectionsQuery struct {
FromID string
Limit int
FromID string
Limit int
Audience string
}
// SetDefaults sets the default values for the query.
@ -62,6 +98,7 @@ func (q *listOutgoingOAuthConnectionsQuery) ToFilter() model.OutgoingOAuthConnec
return model.OutgoingOAuthConnectionGetConnectionsFilter{
OffsetId: q.FromID,
Limit: q.Limit,
Audience: q.Audience,
}
}
@ -77,16 +114,26 @@ func NewListOutgoingOAuthConnectionsQueryFromURLQuery(values url.Values) (*listO
limit := values.Get("limit")
if limit != "" {
limitInt, err := strconv.Atoi(limit)
if err == nil {
if err != nil {
return nil, err
}
query.Limit = limitInt
}
audience := values.Get("audience")
if audience != "" {
query.Audience = audience
}
return query, nil
}
func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Request) {
teamId := r.URL.Query().Get("team_id")
if !checkOutgoingOAuthConnectionReadPermissions(c, teamId) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
@ -103,10 +150,25 @@ func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Req
return
}
connections, errList := service.GetConnections(c.AppContext, query.ToFilter())
if errList != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, errList.Error(), http.StatusInternalServerError)
return
var connections []*model.OutgoingOAuthConnection
if query.Audience != "" {
// If the consumer expects an audience match, use the `GetConnectionByAudience` method to
// retrieve a single connection.
connection, err := service.GetConnectionForAudience(c.AppContext, query.Audience)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
connections = append(connections, connection)
} else {
// If the consumer does not expect an audience match, use the `GetConnections` method to
// retrieve a list of connections that potentially matches the provided audience.
var errList *model.AppError
connections, errList = service.GetConnections(c.AppContext, query.ToFilter())
if errList != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, errList.Error(), http.StatusInternalServerError)
return
}
}
service.SanitizeConnections(connections)
@ -118,6 +180,10 @@ func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Req
}
func getOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
@ -138,3 +204,216 @@ func getOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Reque
return
}
}
func createOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("createOutgoingOauthConnection", audit.Fail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
var inputConnection model.OutgoingOAuthConnection
bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes)
if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.input_error", nil, err.Error(), http.StatusBadRequest)
return
}
audit.AddEventParameterAuditable(auditRec, "outgoing_oauth_connection", &inputConnection)
inputConnection.CreatorId = c.AppContext.Session().UserId
connection, err := service.SaveConnection(c.AppContext, &inputConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.Success()
auditRec.AddEventResultState(connection)
auditRec.AddEventObjectType("outgoing_oauth_connection")
c.LogAudit("client_id=" + connection.ClientId)
service.SanitizeConnection(connection)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(connection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
}
func updateOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("updateOutgoingOAuthConnection", audit.Fail)
defer c.LogAuditRec(auditRec)
audit.AddEventParameter(auditRec, "outgoing_oauth_connection_id", c.Params.OutgoingOAuthConnectionID)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
c.RequireOutgoingOAuthConnectionId()
if c.Err != nil {
return
}
var inputConnection model.OutgoingOAuthConnection
bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes)
if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.input_error", nil, err.Error(), http.StatusBadRequest)
return
}
if inputConnection.Id != c.Params.OutgoingOAuthConnectionID {
c.SetInvalidParam("id")
return
}
currentConnection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.AddEventPriorState(currentConnection)
currentConnection.Patch(&inputConnection)
connection, err := service.UpdateConnection(c.AppContext, currentConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.AddEventObjectType("outgoing_oauth_connection")
auditRec.AddEventResultState(connection)
auditRec.Success()
auditLogExtraInfo := "success"
// Audit log changes to clientID/Client Secret
if connection.ClientId != currentConnection.ClientId {
auditLogExtraInfo += " new_client_id=" + connection.ClientId
}
if connection.ClientSecret != currentConnection.ClientSecret {
auditLogExtraInfo += " new_client_secret"
}
c.LogAudit(auditLogExtraInfo)
service.SanitizeConnection(connection)
if err := json.NewEncoder(w).Encode(connection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
}
func deleteOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("deleteOutgoingOAuthConnection", audit.Fail)
defer c.LogAuditRec(auditRec)
audit.AddEventParameter(auditRec, "outgoing_oauth_connection_id", c.Params.OutgoingOAuthConnectionID)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
c.RequireOutgoingOAuthConnectionId()
if c.Err != nil {
return
}
connection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.delete_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.AddEventPriorState(connection)
if err := service.DeleteConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.delete_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.AddEventObjectType("outgoing_oauth_connection")
auditRec.Success()
ReturnStatusOK(w)
}
// validateOutgoingOAuthConnectionCredentials validates the credentials of an outgoing oauth connection by requesting a token
// with the provided connection configuration. If the credentials are valid, the request will return a 200 status code and
// if the credentials are invalid, the request will return a 400 status code.
func validateOutgoingOAuthConnectionCredentials(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("validateOutgoingOAuthConnectionCredentials", audit.Fail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
// Allow checking connections sent in the body or by id if coming from an already existing
// connection url.
var inputConnection *model.OutgoingOAuthConnection
bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes)
if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error", nil, err.Error(), http.StatusBadRequest)
w.WriteHeader(c.Err.StatusCode)
return
}
if inputConnection.Id != "" && inputConnection.ClientSecret == "" {
var err *model.AppError
var storedConnection *model.OutgoingOAuthConnection
storedConnection, err = service.GetConnection(c.AppContext, inputConnection.Id)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", nil, err.Error(), http.StatusInternalServerError)
w.WriteHeader(c.Err.StatusCode)
return
}
inputConnection.ClientSecret = storedConnection.ClientSecret
}
audit.AddEventParameterAuditable(auditRec, "outgoing_oauth_connection", inputConnection)
resultStatusCode := http.StatusOK
// Try to retrieve a token with the provided credentials
// do not store the token, just check if the credentials are valid and the request can be made
_, err := service.RetrieveTokenForConnection(c.AppContext, inputConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", nil, err.Error(), err.StatusCode)
c.Logger.Error("Failed to retrieve token while validating outgoing oauth connection", logr.Err(err))
resultStatusCode = err.StatusCode
} else {
ReturnStatusOK(w)
}
auditRec.Success()
auditRec.AddEventResultState(inputConnection)
auditRec.AddEventObjectType("outgoing_oauth_connection")
w.WriteHeader(resultStatusCode)
}

File diff suppressed because it is too large Load Diff

View File

@ -60,14 +60,13 @@ type Channels struct {
// previously fetched notices
cachedNotices model.ProductNotices
AccountMigration einterfaces.AccountMigrationInterface
Compliance einterfaces.ComplianceInterface
DataRetention einterfaces.DataRetentionInterface
MessageExport einterfaces.MessageExportInterface
Saml einterfaces.SamlInterface
Notification einterfaces.NotificationInterface
OutgoingOAuthConnection einterfaces.OutgoingOAuthConnectionInterface
Ldap einterfaces.LdapInterface
AccountMigration einterfaces.AccountMigrationInterface
Compliance einterfaces.ComplianceInterface
DataRetention einterfaces.DataRetentionInterface
MessageExport einterfaces.MessageExportInterface
Saml einterfaces.SamlInterface
Notification einterfaces.NotificationInterface
Ldap einterfaces.LdapInterface
// These are used to prevent concurrent upload requests
// for a given upload session which could cause inconsistencies
@ -177,9 +176,6 @@ func NewChannels(services map[product.ServiceKey]any) (*Channels, error) {
if notificationInterface != nil {
ch.Notification = notificationInterface(New(ServerConnector(ch)))
}
if outgoingOauthConnectionInterface != nil {
ch.OutgoingOAuthConnection = outgoingOauthConnectionInterface(New(ServerConnector(ch)))
}
if samlInterfaceNew != nil {
ch.Saml = samlInterfaceNew(New(ServerConnector(ch)))
if err := ch.Saml.ConfigureSP(request.EmptyContext(s.Log())); err != nil {

View File

@ -485,6 +485,23 @@ func (a *App) DoCommandRequest(rctx request.CTX, cmd *model.Command, p url.Value
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
var accessToken *model.OutgoingOAuthConnectionToken
// Retrieve an access token from a connection if one exists to use for the webhook request
if a.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && *a.Config().ServiceSettings.EnableOutgoingOAuthConnections && a.OutgoingOAuthConnections() != nil {
connection, err := a.OutgoingOAuthConnections().GetConnectionForAudience(rctx, cmd.URL)
if err != nil {
a.Log().Error("Failed to find an outgoing oauth connection for the webhook", mlog.Err(err))
}
if connection != nil {
accessToken, err = a.OutgoingOAuthConnections().RetrieveTokenForConnection(rctx, connection)
if err != nil {
a.Log().Error("Failed to retrieve token for outgoing oauth connection", mlog.Err(err))
}
}
}
// Prepare the request
var req *http.Request
var err error
@ -506,7 +523,14 @@ func (a *App) DoCommandRequest(rctx request.CTX, cmd *model.Command, p url.Value
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Token "+cmd.Token)
if cmd.Token != "" {
req.Header.Set("Authorization", "Token "+cmd.Token)
}
if accessToken != nil {
req.Header.Set("Authorization", accessToken.AsHeaderValue())
}
if cmd.Method == model.CommandMethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}

View File

@ -1141,6 +1141,21 @@ func (a *App) getAddIPFilterPermissionsMigration() (permissionsMap, error) {
return t, nil
}
func (a *App) getAddOutgoingOAuthConnectionsPermissions() (permissionsMap, error) {
t := []permissionTransformation{}
permissionManageOutgoingOAuthConnections := []string{
model.PermissionManageOutgoingOAuthConnections.Id,
}
t = append(t, permissionTransformation{
On: permissionOr(isExactRole(model.SystemAdminRoleId)),
Add: permissionManageOutgoingOAuthConnections,
})
return t, nil
}
// DoPermissionsMigrations execute all the permissions migrations need by the current version.
func (a *App) DoPermissionsMigrations() error {
return a.Srv().doPermissionsMigrations()
@ -1186,6 +1201,7 @@ func (s *Server) doPermissionsMigrations() error {
{Key: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Migration: a.getAddCustomUserGroupsPermissionRestore},
{Key: model.MigrationKeyAddReadChannelContentPermissions, Migration: a.getAddChannelReadContentPermissions},
{Key: model.MigrationKeyAddIPFilteringPermissions, Migration: a.getAddIPFilterPermissionsMigration},
{Key: model.MigrationKeyAddOutgoingOAuthConnectionsPermissions, Migration: a.getAddOutgoingOAuthConnectionsPermissions},
}
roles, err := s.Store().Role().GetAll()

View File

@ -402,6 +402,10 @@ func NewServer(options ...Option) (*Server, error) {
s.IPFiltering = ipFilteringInterface(app)
}
if outgoingOauthConnectionInterface != nil {
s.OutgoingOAuthConnection = outgoingOauthConnectionInterface(app)
}
s.clusterLeaderListenerId = s.AddClusterLeaderChangedListener(func() {
mlog.Info("Cluster leader changed. Determining if job schedulers should be running:", mlog.Bool("isLeader", s.IsLeader()))
if s.Jobs != nil {

View File

@ -17,6 +17,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
type InfiniteReader struct {
@ -459,6 +461,51 @@ func TestDoCommandRequest(t *testing.T) {
require.NotNil(t, resp)
assert.Equal(t, "Hello, World!", resp.Text)
})
t.Run("with a url that matches an outgoing oauth connection", func(t *testing.T) {
outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{}
outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection
outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections
th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true)
t.Cleanup(func() {
th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl
th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig
})
th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface
serverCommand := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.Copy(w, strings.NewReader(r.Header.Get("Authorization")))
}))
defer serverCommand.Close()
connection := &model.OutgoingOAuthConnection{
Id: model.NewId(),
Name: "test",
ClientId: "test",
ClientSecret: "test",
CreatorId: model.NewId(),
OAuthTokenURL: "fake",
GrantType: model.OutgoingOAuthConnectionGrantTypeClientCredentials,
Audiences: model.StringArray{
serverCommand.URL,
},
}
outgoingOauthIface.Mock.On("GetConnectionForAudience", mock.Anything, serverCommand.URL).Return(connection, nil)
outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything)
outgoingOauthIface.Mock.On("RetrieveTokenForConnection", mock.Anything, connection).Return(&model.OutgoingOAuthConnectionToken{
AccessToken: "token",
TokenType: "type",
}, nil)
_, resp, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: serverCommand.URL}, url.Values{})
require.Nil(t, err)
require.NotNil(t, resp)
// Ensure that the Authorization header was set correctly by reading the body from the command response
// which was set to the Authorization header by the command handler.
assert.Equal(t, "type token", resp.Text)
})
}
func TestMentionsToTeamMembers(t *testing.T) {

View File

@ -117,7 +117,27 @@ func (a *App) TriggerWebhook(c request.CTX, payload *model.OutgoingWebhookPayloa
go func() {
defer wg.Done()
webhookResp, err := a.doOutgoingWebhookRequest(url, body, contentType)
var accessToken *model.OutgoingOAuthConnectionToken
// Retrieve an access token from a connection if one exists to use for the webhook request
if a.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && *a.Config().ServiceSettings.EnableOutgoingOAuthConnections && a.OutgoingOAuthConnections() != nil {
connection, err := a.OutgoingOAuthConnections().GetConnectionForAudience(c, url)
if err != nil {
c.Logger().Error("Failed to find an outgoing oauth connection for the webhook", mlog.Err(err))
return
}
if connection != nil {
accessToken, err = a.OutgoingOAuthConnections().RetrieveTokenForConnection(c, connection)
if err != nil {
c.Logger().Error("Failed to retrieve token for outgoing oauth connection", mlog.Err(err))
return
}
}
}
webhookResp, err := a.doOutgoingWebhookRequest(url, body, contentType, accessToken)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
c.Logger().Error("Outgoing Webhook POST timed out. Consider increasing ServiceSettings.OutgoingIntegrationRequestsTimeout.", mlog.Err(err))
@ -162,7 +182,7 @@ func (a *App) TriggerWebhook(c request.CTX, payload *model.OutgoingWebhookPayloa
wg.Wait()
}
func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType string) (*model.OutgoingWebhookResponse, error) {
func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType string, accessToken *model.OutgoingOAuthConnectionToken) (*model.OutgoingWebhookResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
@ -174,6 +194,10 @@ func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType s
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json")
if accessToken != nil {
req.Header.Add("Authorization", accessToken.AsHeaderValue())
}
resp, err := a.Srv().outgoingWebhookClient.Do(req)
if err != nil {
return nil, err

View File

@ -6,6 +6,7 @@ package app
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@ -783,7 +784,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.NoError(t, err)
require.NotNil(t, resp)
@ -797,7 +798,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.Error(t, err)
require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id)
})
@ -808,7 +809,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.Error(t, err)
require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id)
})
@ -819,7 +820,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.Error(t, err)
require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id)
})
@ -838,7 +839,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewInt64(1)
})
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.Error(t, err)
require.IsType(t, &url.Error{}, err)
})
@ -855,7 +856,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewInt64(2)
})
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.NoError(t, err)
require.NotNil(t, resp)
assert.NotNil(t, resp.Text)
@ -867,8 +868,22 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.NoError(t, err)
require.Nil(t, resp)
})
t.Run("with auth token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.Copy(w, strings.NewReader(fmt.Sprintf(`{"text":"%s"}`, r.Header.Get("Authorization"))))
}))
defer server.Close()
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", &model.OutgoingOAuthConnectionToken{
AccessToken: "test",
TokenType: "Bearer",
})
require.NoError(t, err)
require.Equal(t, `Bearer test`, *resp.Text)
})
}

View File

@ -5,6 +5,9 @@ package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/mattermost/squirrel"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
@ -49,9 +52,33 @@ func (s *SqlOutgoingOAuthConnectionStore) UpdateConnection(c request.CTX, conn *
return nil, err
}
if _, err := s.GetMasterX().NamedExec(`UPDATE OutgoingOAuthConnections SET
Name=:Name, ClientId=:ClientId, ClientSecret=:ClientSecret, UpdateAt=:UpdateAt, OAuthTokenURL=:OAuthTokenURL, GrantType=:GrantType, Audiences=:Audiences
WHERE Id=:Id`, conn); err != nil {
query := s.getQueryBuilder().Update("OutgoingOAuthConnections").Where(sq.Eq{"Id": conn.Id}).Set("UpdateAt", conn.UpdateAt)
if conn.Name != "" {
query = query.Set("Name", conn.Name)
}
if conn.ClientId != "" {
query = query.Set("ClientId", conn.ClientId)
}
if conn.ClientSecret != "" {
query = query.Set("ClientSecret", conn.ClientSecret)
}
if conn.OAuthTokenURL != "" {
query = query.Set("OAuthTokenURL", conn.OAuthTokenURL)
}
if conn.GrantType != "" {
query = query.Set("GrantType", conn.GrantType)
}
if len(conn.Audiences) > 0 {
query = query.Set("Audiences", conn.Audiences)
}
if conn.CredentialsUsername != nil {
query = query.Set("CredentialsUsername", conn.CredentialsUsername)
}
if conn.CredentialsPassword != nil {
query = query.Set("CredentialsPassword", conn.CredentialsPassword)
}
if _, err := s.GetMasterX().ExecBuilder(query); err != nil {
return nil, errors.Wrap(err, "failed to update OutgoingOAuthConnection")
}
return conn, nil
@ -82,6 +109,10 @@ func (s *SqlOutgoingOAuthConnectionStore) GetConnections(c request.CTX, filters
query = query.Where("Id > ?", filters.OffsetId)
}
if filters.Audience != "" {
query = query.Where(sq.Like{"Audiences": fmt.Sprint("%", filters.Audience, "%")})
}
if err := s.GetReplicaX().SelectBuilder(&conns, query); err != nil {
return nil, errors.Wrap(err, "failed to get OutgoingOAuthConnections")
}

View File

@ -52,6 +52,10 @@ func TestOutgoingOAuthConnectionStore(t *testing.T, rctx request.CTX, ss store.S
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
testGetOutgoingOAuthConnection(t, ss)
})
t.Run("GetConnectionsByAudience", func(t *testing.T) {
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
testGetOutgoingOAuthConnectionByAudience(t, ss)
})
t.Run("GetConnections", func(t *testing.T) {
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
testGetOutgoingOAuthConnections(t, ss)
@ -159,6 +163,106 @@ func testUpdateOutgoingOAuthConnection(t *testing.T, ss store.Store) {
require.NoError(t, err)
require.Equal(t, connection, storeConn)
})
t.Run("patch", func(t *testing.T) {
t.Run("name", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.Name = "Updated Name"
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("client id", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.ClientId = "Updated ClientId"
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("client secret", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.ClientSecret = "Updated ClientSecret"
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("oauth token url", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.OAuthTokenURL = "https://nowhere.com/updated"
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("grant type", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.GrantType = model.OutgoingOAuthConnectionGrantTypeClientCredentials
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("audiences", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.Audiences = model.StringArray{"https://nowhere.com/updated"}
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("credentials username", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
username := "updated username"
connection.CredentialsUsername = &username
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("credentials password", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
password := "updated password"
connection.CredentialsPassword = &password
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
})
}
func testGetOutgoingOAuthConnection(t *testing.T, ss store.Store) {
@ -172,6 +276,74 @@ func testGetOutgoingOAuthConnection(t *testing.T, ss store.Store) {
})
}
func runAudienceTests(t *testing.T, ss store.Store, connection *model.OutgoingOAuthConnection) {
c := request.TestContext(t)
t.Run("find by host only", func(t *testing.T) {
conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "knowhere.com"})
require.NoError(t, err)
require.Len(t, conn, 1)
require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn)
})
t.Run("find by host and path", func(t *testing.T) {
conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "knowhere.com/audience"})
require.NoError(t, err)
require.Len(t, conn, 1)
require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn)
})
t.Run("find by full url", func(t *testing.T) {
conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "https://knowhere.com/audience"})
require.NoError(t, err)
require.Len(t, conn, 1)
require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn)
})
t.Run("non-existent", func(t *testing.T) {
conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "https://mattermost.com"})
require.NoError(t, err)
require.Empty(t, conn)
})
}
func testGetOutgoingOAuthConnectionByAudience(t *testing.T, ss store.Store) {
t.Run("get non-existing", func(t *testing.T) {
c := request.TestContext(t)
nonExistingId := model.NewId()
var expected *store.ErrNotFound
_, err := ss.OutgoingOAuthConnection().GetConnection(c, nonExistingId)
require.ErrorAs(t, err, &expected)
})
t.Run("get existing (single audience)", func(t *testing.T) {
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
c := request.TestContext(t)
connection := newValidOutgoingOAuthConnection()
connection.Audiences = []string{"https://knowhere.com/audience"}
var err error
connection, err = ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
runAudienceTests(t, ss, connection)
})
t.Run("get existing (multiple audiences)", func(t *testing.T) {
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
c := request.TestContext(t)
connection := newValidOutgoingOAuthConnection()
connection.Audiences = []string{"https://knowhere.com/audience", "https://example.com"}
var err error
connection, err = ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
runAudienceTests(t, ss, connection)
})
}
func testGetOutgoingOAuthConnections(t *testing.T, ss store.Store) {
c := request.TestContext(t)

View File

@ -74,6 +74,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
systemStore.On("GetByName", model.MigrationKeyDeleteEmptyDrafts).Return(&model.System{Name: model.MigrationKeyDeleteEmptyDrafts, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyDeleteOrphanDrafts).Return(&model.System{Name: model.MigrationKeyDeleteOrphanDrafts, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddIPFilteringPermissions).Return(&model.System{Name: model.MigrationKeyAddIPFilteringPermissions, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddOutgoingOAuthConnectionsPermissions).Return(&model.System{Name: model.MigrationKeyAddOutgoingOAuthConnectionsPermissions, Value: "true"}, nil)
systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil)
systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil)
systemStore.On("GetByName", "elasticsearch_fix_channel_index_migration").Return(&model.System{Name: "elasticsearch_fix_channel_index_migration", Value: "true"}, nil)

View File

@ -103,6 +103,7 @@ func setupClientTests(cfg *model.Config) {
*cfg.ServiceSettings.EnableCustomEmoji = true
*cfg.ServiceSettings.EnableIncomingWebhooks = false
*cfg.ServiceSettings.EnableOutgoingWebhooks = false
*cfg.ServiceSettings.EnableOutgoingOAuthConnections = false
}
func executeTestCommand(command *exec.Cmd) {

View File

@ -29,6 +29,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["GoogleDeveloperKey"] = *c.ServiceSettings.GoogleDeveloperKey
props["EnableIncomingWebhooks"] = strconv.FormatBool(*c.ServiceSettings.EnableIncomingWebhooks)
props["EnableOutgoingWebhooks"] = strconv.FormatBool(*c.ServiceSettings.EnableOutgoingWebhooks)
props["EnableOutgoingOAuthConnections"] = strconv.FormatBool(*c.ServiceSettings.EnableOutgoingOAuthConnections)
props["EnableCommands"] = strconv.FormatBool(*c.ServiceSettings.EnableCommands)
props["EnablePostUsernameOverride"] = strconv.FormatBool(*c.ServiceSettings.EnablePostUsernameOverride)
props["EnablePostIconOverride"] = strconv.FormatBool(*c.ServiceSettings.EnablePostIconOverride)

View File

@ -59,6 +59,34 @@ func (_m *OutgoingOAuthConnectionInterface) GetConnection(rctx request.CTX, id s
return r0, r1
}
// GetConnectionForAudience provides a mock function with given fields: rctx, url
func (_m *OutgoingOAuthConnectionInterface) GetConnectionForAudience(rctx request.CTX, url string) (*model.OutgoingOAuthConnection, *model.AppError) {
ret := _m.Called(rctx, url)
var r0 *model.OutgoingOAuthConnection
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.OutgoingOAuthConnection, *model.AppError)); ok {
return rf(rctx, url)
}
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.OutgoingOAuthConnection); ok {
r0 = rf(rctx, url)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.OutgoingOAuthConnection)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok {
r1 = rf(rctx, url)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// GetConnections provides a mock function with given fields: rctx, filters
func (_m *OutgoingOAuthConnectionInterface) GetConnections(rctx request.CTX, filters model.OutgoingOAuthConnectionGetConnectionsFilter) ([]*model.OutgoingOAuthConnection, *model.AppError) {
ret := _m.Called(rctx, filters)
@ -87,6 +115,34 @@ func (_m *OutgoingOAuthConnectionInterface) GetConnections(rctx request.CTX, fil
return r0, r1
}
// RetrieveTokenForConnection provides a mock function with given fields: rctx, conn
func (_m *OutgoingOAuthConnectionInterface) RetrieveTokenForConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnectionToken, *model.AppError) {
ret := _m.Called(rctx, conn)
var r0 *model.OutgoingOAuthConnectionToken
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnectionToken, *model.AppError)); ok {
return rf(rctx, conn)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnection) *model.OutgoingOAuthConnectionToken); ok {
r0 = rf(rctx, conn)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.OutgoingOAuthConnectionToken)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.OutgoingOAuthConnection) *model.AppError); ok {
r1 = rf(rctx, conn)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// SanitizeConnection provides a mock function with given fields: conn
func (_m *OutgoingOAuthConnectionInterface) SanitizeConnection(conn *model.OutgoingOAuthConnection) {
_m.Called(conn)

View File

@ -17,4 +17,7 @@ type OutgoingOAuthConnectionInterface interface {
SanitizeConnection(conn *model.OutgoingOAuthConnection)
SanitizeConnections(conns []*model.OutgoingOAuthConnection)
GetConnectionForAudience(rctx request.CTX, url string) (*model.OutgoingOAuthConnection, *model.AppError)
RetrieveTokenForConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnectionToken, *model.AppError)
}

View File

@ -42,4 +42,6 @@ import (
_ "github.com/mattermost/enterprise/license"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/ip_filtering"
// Needed to ensure the init() method in the EE gets run
_ "github.com/mattermost/enterprise/outgoing_oauth_connections"
)

View File

@ -1661,6 +1661,18 @@
"id": "api.context.mfa_required.app_error",
"translation": "Multi-factor authentication is required on this server."
},
{
"id": "api.context.outgoing_oauth_connection.create_connection.app_error",
"translation": "There was an error while creating the outgoing OAuth connection."
},
{
"id": "api.context.outgoing_oauth_connection.create_connection.input_error",
"translation": "Invalid input parameters."
},
{
"id": "api.context.outgoing_oauth_connection.delete_connection.app_error",
"translation": "There was an error while deleting the outgoing OAuth connection."
},
{
"id": "api.context.outgoing_oauth_connection.list_connections.app_error",
"translation": "There was an error while listing outgoing OAuth connections."
@ -1670,8 +1682,24 @@
"translation": "Invalid input parameters."
},
{
"id": "api.context.outgoing_oauth_connection.not_available.feature_flag",
"translation": "This feature is restricted by a feature flag."
"id": "api.context.outgoing_oauth_connection.not_available.configuration_disabled",
"translation": "Outgoing OAuth connections are not available on this server."
},
{
"id": "api.context.outgoing_oauth_connection.update_connection.app_error",
"translation": "There was an error while updating the outgoing OAuth connection."
},
{
"id": "api.context.outgoing_oauth_connection.update_connection.input_error",
"translation": "Invalid input parameters."
},
{
"id": "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error",
"translation": "There was an error while validating the outgoing OAuth connection credentials."
},
{
"id": "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error",
"translation": "Couldn't retrieve credentials with the specified connection configuration."
},
{
"id": "api.context.permissions.app_error",
@ -8302,10 +8330,26 @@
"id": "ent.migration.migratetosaml.username_already_used_by_other_user",
"translation": "Username already used by another Mattermost user."
},
{
"id": "ent.outgoing_oauth_connections.authenticate.app_error",
"translation": "There was an error while authenticating the outgoing oauth connection: {{ .Error }}"
},
{
"id": "ent.outgoing_oauth_connections.connection_matching_audience_exists.app_error",
"translation": "There is already an outgoing oauth connection for the provided audience."
},
{
"id": "ent.outgoing_oauth_connections.connection_matching_audience_exists.not_found",
"translation": "There is no outgoing oauth connection for the provided audience."
},
{
"id": "ent.outgoing_oauth_connections.delete_connection.app_error",
"translation": "There was an error while deleting the outgoing oauth connection."
},
{
"id": "ent.outgoing_oauth_connections.feature_disabled",
"translation": "Outgoing OAuth connections are not available on this server."
},
{
"id": "ent.outgoing_oauth_connections.get_connection.app_error",
"translation": "There was an error retrieving the outgoing oauth connection."
@ -8314,17 +8358,45 @@
"id": "ent.outgoing_oauth_connections.get_connection.not_found.app_error",
"translation": "The outgoing oauth connection was not found."
},
{
"id": "ent.outgoing_oauth_connections.get_connection_for_audience.app_error",
"translation": "There was an error retrieving the outgoing oauth connection for the audience."
},
{
"id": "ent.outgoing_oauth_connections.get_connection_for_audience.not_found.app_error",
"translation": "The outgoing oauth connection for the provided audience was not found."
},
{
"id": "ent.outgoing_oauth_connections.get_connections.app_error",
"translation": "There was an error retrieving the outgoing oauth connections."
},
{
"id": "ent.outgoing_oauth_connections.license_disable.app_error",
"translation": "Your license does not support outgoing oauth connections."
},
{
"id": "ent.outgoing_oauth_connections.save_connection.app_error",
"translation": "There was an error saving the outgoing oauth connection."
"translation": "There was an error saving the outgoing oauth connection: {{ .Error }}"
},
{
"id": "ent.outgoing_oauth_connections.save_connection.audience_duplicated",
"translation": "There is already an outgoing oauth connection for the provided audience: {{ .Audience }}"
},
{
"id": "ent.outgoing_oauth_connections.save_connection.audience_invalid",
"translation": "The provided audience is invalid: {{ .Error }}"
},
{
"id": "ent.outgoing_oauth_connections.update_connection.app_error",
"translation": "There was an error updating the outgoing oauth connection."
"translation": "There was an error updating the outgoing oauth connection: {{ .Error }}"
},
{
"id": "ent.outgoing_oauth_connections.update_connection.audience_duplicated",
"translation": "There is already an outgoing oauth connection for the provided audience: {{ .Audience }}"
},
{
"id": "ent.outgoing_oauth_connections.update_connection.audience_invalid",
"translation": "The provided audience is invalid: {{ .Error }}"
},
{
"id": "ent.saml.attribute.app_error",
@ -9668,7 +9740,7 @@
},
{
"id": "model.outgoing_oauth_connection.is_valid.audience.error",
"translation": "Some audience URL is incorrect."
"translation": "Audience URL is invalid: {{ .Url }}"
},
{
"id": "model.outgoing_oauth_connection.is_valid.client_id.error",

View File

@ -410,6 +410,7 @@ func (ts *TelemetryService) trackConfig() {
"enable_insecure_outgoing_connections": *cfg.ServiceSettings.EnableInsecureOutgoingConnections,
"enable_incoming_webhooks": cfg.ServiceSettings.EnableIncomingWebhooks,
"enable_outgoing_webhooks": cfg.ServiceSettings.EnableOutgoingWebhooks,
"enable_outgoing_oauth_connections": cfg.ServiceSettings.EnableOutgoingOAuthConnections,
"enable_commands": *cfg.ServiceSettings.EnableCommands,
"outgoing_integrations_requests_timeout": cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout,
"enable_post_username_override": cfg.ServiceSettings.EnablePostUsernameOverride,

View File

@ -485,7 +485,7 @@ func (c *Client4) outgoingOAuthConnectionsRoute() string {
}
func (c *Client4) outgoingOAuthConnectionRoute(id string) string {
return fmt.Sprintf("/oauth/outgoing_connections/%s", id)
return fmt.Sprintf("%s/%s", c.outgoingOAuthConnectionsRoute(), id)
}
func (c *Client4) jobsRoute() string {
@ -6023,8 +6023,8 @@ func (c *Client4) GetOAuthAccessToken(ctx context.Context, data url.Values) (*Ac
// OutgoingOAuthConnection section
// GetOutgoingOAuthConnections retrieves the outgoing OAuth connections.
func (c *Client4) GetOutgoingOAuthConnections(ctx context.Context, fromID string, limit int) ([]*OutgoingOAuthConnection, *Response, error) {
r, err := c.DoAPIGet(ctx, c.outgoingOAuthConnectionsRoute(), "")
func (c *Client4) GetOutgoingOAuthConnections(ctx context.Context, filters OutgoingOAuthConnectionGetConnectionsFilter) ([]*OutgoingOAuthConnection, *Response, error) {
r, err := c.DoAPIGet(ctx, c.outgoingOAuthConnectionsRoute()+"?"+filters.ToURLValues().Encode(), "")
if err != nil {
return nil, BuildResponse(r), err
}
@ -6050,6 +6050,53 @@ func (c *Client4) GetOutgoingOAuthConnection(ctx context.Context, id string) (*O
return connection, BuildResponse(r), nil
}
// DeleteOutgoingOAuthConnection deletes the outgoing OAuth connection with the given ID.
func (c *Client4) DeleteOutgoingOAuthConnection(ctx context.Context, id string) (*Response, error) {
r, err := c.DoAPIDelete(ctx, c.outgoingOAuthConnectionRoute(id))
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
// UpdateOutgoingOAuthConnection updates the outgoing OAuth connection with the given ID.
func (c *Client4) UpdateOutgoingOAuthConnection(ctx context.Context, connection *OutgoingOAuthConnection) (*OutgoingOAuthConnection, *Response, error) {
buf, err := json.Marshal(connection)
if err != nil {
return nil, nil, NewAppError("UpdateOutgoingOAuthConnection", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPutBytes(ctx, c.outgoingOAuthConnectionRoute(connection.Id), buf)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var resultConnection OutgoingOAuthConnection
if err := json.NewDecoder(r.Body).Decode(&resultConnection); err != nil {
return nil, nil, NewAppError("UpdateOutgoingOAuthConnection", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &resultConnection, BuildResponse(r), nil
}
// CreateOutgoingOAuthConnection creates a new outgoing OAuth connection.
func (c *Client4) CreateOutgoingOAuthConnection(ctx context.Context, connection *OutgoingOAuthConnection) (*OutgoingOAuthConnection, *Response, error) {
buf, err := json.Marshal(connection)
if err != nil {
return nil, nil, NewAppError("CreateOutgoingOAuthConnection", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
r, err := c.DoAPIPostBytes(ctx, c.outgoingOAuthConnectionsRoute(), buf)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var resultConnection OutgoingOAuthConnection
if err := json.NewDecoder(r.Body).Decode(&resultConnection); err != nil {
return nil, nil, NewAppError("CreateOutgoingOAuthConnection", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &resultConnection, BuildResponse(r), nil
}
// Elasticsearch Section
// TestElasticsearch will attempt to connect to the configured Elasticsearch server and return OK if configured.

View File

@ -312,6 +312,7 @@ type ServiceSettings struct {
EnableOAuthServiceProvider *bool `access:"integrations_integration_management"`
EnableIncomingWebhooks *bool `access:"integrations_integration_management"`
EnableOutgoingWebhooks *bool `access:"integrations_integration_management"`
EnableOutgoingOAuthConnections *bool `access:"integrations_integration_management"`
EnableCommands *bool `access:"integrations_integration_management"`
OutgoingIntegrationRequestsTimeout *int64 `access:"integrations_integration_management"` // In seconds.
EnablePostUsernameOverride *bool `access:"integrations_integration_management"`
@ -515,6 +516,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
s.EnableOutgoingWebhooks = NewBool(true)
}
if s.EnableOutgoingOAuthConnections == nil {
s.EnableOutgoingOAuthConnections = NewBool(false)
}
if s.OutgoingIntegrationRequestsTimeout == nil {
s.OutgoingIntegrationRequestsTimeout = NewInt64(OutgoingIntegrationRequestsDefaultTimeout)
}

View File

@ -50,8 +50,6 @@ type FeatureFlags struct {
ConsumePostHook bool
CloudAnnualRenewals bool
OutgoingOAuthConnections bool
}
func (f *FeatureFlags) SetDefaults() {
@ -71,7 +69,6 @@ func (f *FeatureFlags) SetDefaults() {
f.CloudIPFiltering = false
f.ConsumePostHook = false
f.CloudAnnualRenewals = false
f.OutgoingOAuthConnections = false
}
// ToMap returns the feature flags as a map[string]string

View File

@ -46,4 +46,5 @@ const (
MigrationKeyDeleteEmptyDrafts = "delete_empty_drafts_migration"
MigrationKeyDeleteOrphanDrafts = "delete_orphan_drafts_migration"
MigrationKeyAddIPFilteringPermissions = "add_ip_filtering_permissions"
MigrationKeyAddOutgoingOAuthConnectionsPermissions = "add_outgoing_oauth_connections_permissions"
)

View File

@ -4,7 +4,9 @@
package model
import (
"fmt"
"net/http"
"net/url"
"unicode/utf8"
)
@ -49,12 +51,42 @@ func (oa *OutgoingOAuthConnection) Auditable() map[string]interface{} {
// Sanitize removes any sensitive fields from the OutgoingOAuthConnection object.
func (oa *OutgoingOAuthConnection) Sanitize() {
oa.ClientId = ""
oa.ClientSecret = ""
oa.CredentialsUsername = nil
oa.CredentialsPassword = nil
}
// Patch updates the OutgoingOAuthConnection object with the non-empty fields from the given connection.
func (oa *OutgoingOAuthConnection) Patch(conn *OutgoingOAuthConnection) {
if conn == nil {
return
}
if conn.Name != "" {
oa.Name = conn.Name
}
if conn.ClientId != "" {
oa.ClientId = conn.ClientId
}
if conn.ClientSecret != "" {
oa.ClientSecret = conn.ClientSecret
}
if conn.OAuthTokenURL != "" {
oa.OAuthTokenURL = conn.OAuthTokenURL
}
if conn.GrantType != "" {
oa.GrantType = conn.GrantType
}
if len(conn.Audiences) > 0 {
oa.Audiences = conn.Audiences
}
if conn.CredentialsUsername != nil {
oa.CredentialsUsername = conn.CredentialsUsername
}
if conn.CredentialsPassword != nil {
oa.CredentialsPassword = conn.CredentialsPassword
}
}
// IsValid validates the object and returns an error if it isn't properly configured
func (oa *OutgoingOAuthConnection) IsValid() *AppError {
if !IsValidId(oa.Id) {
@ -85,11 +117,11 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError {
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.client_secret.error", nil, "id="+oa.Id, http.StatusBadRequest)
}
if oa.OAuthTokenURL == "" || utf8.RuneCountInString(oa.OAuthTokenURL) > 256 {
if !IsValidHTTPURL(oa.OAuthTokenURL) || utf8.RuneCountInString(oa.OAuthTokenURL) > 256 {
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.oauth_token_url.error", nil, "id="+oa.Id, http.StatusBadRequest)
}
if err := oa.IsValidGrantType(); err != nil {
if err := oa.HasValidGrantType(); err != nil {
return err
}
@ -100,7 +132,7 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError {
if len(oa.Audiences) > 0 {
for _, audience := range oa.Audiences {
if !IsValidHTTPURL(audience) {
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.audience.error", nil, "id="+oa.Id, http.StatusBadRequest)
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.audience.error", map[string]any{"Url": audience}, "id="+oa.Id, http.StatusBadRequest)
}
}
}
@ -108,8 +140,8 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError {
return nil
}
// IsValidGrantType validates the grant type and its parameters returning an error if it isn't properly configured
func (oa *OutgoingOAuthConnection) IsValidGrantType() *AppError {
// HasValidGrantType validates the grant type and its parameters returning an error if it isn't properly configured
func (oa *OutgoingOAuthConnection) HasValidGrantType() *AppError {
if !oa.GrantType.IsValid() {
return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.grant_type.error", nil, "id="+oa.Id, http.StatusBadRequest)
}
@ -149,6 +181,12 @@ func (oa *OutgoingOAuthConnection) Etag() string {
type OutgoingOAuthConnectionGetConnectionsFilter struct {
OffsetId string
Limit int
Audience string
// TeamId is not used as a filter but as a way to check if the current user has permission to
// access the outgoing oauth connection for the given team in order to use them in the slash
// commands and outgoing webhooks.
TeamId string
}
// SetDefaults sets the default values for the filter
@ -157,3 +195,36 @@ func (oaf *OutgoingOAuthConnectionGetConnectionsFilter) SetDefaults() {
oaf.Limit = defaultGetConnectionsLimit
}
}
// ToURLValues converts the filter to url.Values
func (oaf *OutgoingOAuthConnectionGetConnectionsFilter) ToURLValues() url.Values {
v := url.Values{}
if oaf.Limit > 0 {
v.Set("limit", fmt.Sprintf("%d", oaf.Limit))
}
if oaf.OffsetId != "" {
v.Set("offset_id", oaf.OffsetId)
}
if oaf.Audience != "" {
v.Set("audience", oaf.Audience)
}
if oaf.TeamId != "" {
v.Set("team_id", oaf.TeamId)
}
return v
}
// OutgoingOAuthConnectionToken is used to return the token for an outgoing connection oauth
// authentication request
type OutgoingOAuthConnectionToken struct {
AccessToken string
TokenType string
}
func (ooct *OutgoingOAuthConnectionToken) AsHeaderValue() string {
return ooct.TokenType + " " + ooct.AccessToken
}

View File

@ -13,16 +13,18 @@ var (
func newValidOutgoingOAuthConnection() *OutgoingOAuthConnection {
return &OutgoingOAuthConnection{
Id: NewId(),
CreatorId: NewId(),
Name: "Test Connection",
ClientId: NewId(),
ClientSecret: NewId(),
OAuthTokenURL: "https://nowhere.com/oauth/token",
GrantType: OutgoingOAuthConnectionGrantTypeClientCredentials,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
Audiences: []string{"https://nowhere.com"},
Id: NewId(),
CreatorId: NewId(),
Name: "Test Connection",
ClientId: NewId(),
ClientSecret: NewId(),
CredentialsUsername: NewString(NewId()),
CredentialsPassword: NewString(NewId()),
OAuthTokenURL: "https://nowhere.com/oauth/token",
GrantType: OutgoingOAuthConnectionGrantTypeClientCredentials,
CreateAt: GetMillis(),
UpdateAt: GetMillis(),
Audiences: []string{"https://nowhere.com"},
}
}
@ -318,8 +320,66 @@ func TestOutgoingOAuthConnectionSanitize(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Sanitize()
require.Empty(t, oa.ClientId)
require.NotEmpty(t, oa.ClientId)
require.Empty(t, oa.ClientSecret)
require.Empty(t, oa.CredentialsUsername)
require.NotEmpty(t, oa.CredentialsUsername)
require.Empty(t, oa.CredentialsPassword)
}
func TestOutgoingOAuthConnectionPatch(t *testing.T) {
t.Run("name", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{Name: "new name"})
require.Equal(t, "new name", oa.Name)
})
t.Run("client_id", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{ClientId: "new client id"})
require.Equal(t, "new client id", oa.ClientId)
})
t.Run("client_secret", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{ClientSecret: "new client secret"})
require.Equal(t, "new client secret", oa.ClientSecret)
})
t.Run("oauth_token_url", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{OAuthTokenURL: "new oauth token url"})
require.Equal(t, "new oauth token url", oa.OAuthTokenURL)
})
t.Run("grant_type", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{GrantType: OutgoingOAuthConnectionGrantTypePassword})
require.Equal(t, OutgoingOAuthConnectionGrantTypePassword, oa.GrantType)
})
t.Run("audiences", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{Audiences: StringArray{"new audience"}})
require.Equal(t, StringArray{"new audience"}, oa.Audiences)
})
t.Run("credentials_username", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{CredentialsUsername: &someString})
require.Equal(t, &someString, oa.CredentialsUsername)
})
t.Run("credentials_password", func(t *testing.T) {
oa := newValidOutgoingOAuthConnection()
oa.Patch(&OutgoingOAuthConnection{CredentialsPassword: &someString})
require.Equal(t, &someString, oa.CredentialsPassword)
})
}

View File

@ -388,6 +388,8 @@ var ChannelModeratedPermissionsMap map[string]string
var SysconsoleReadPermissions []*Permission
var SysconsoleWritePermissions []*Permission
var PermissionManageOutgoingOAuthConnections *Permission
func initializePermissions() {
PermissionInviteUser = &Permission{
"invite_user",
@ -2125,6 +2127,13 @@ func initializePermissions() {
PermissionScopeSystem,
}
PermissionManageOutgoingOAuthConnections = &Permission{
"manage_outgoing_oauth_connections",
"authentication.permissions.manage_outgoing_oauth_connections.name",
"authentication.permissions.manage_outgoing_oauth_connections.description",
PermissionScopeSystem,
}
SysconsoleReadPermissions = []*Permission{
PermissionSysconsoleReadAboutEditionAndLicense,
PermissionSysconsoleReadBilling,
@ -2317,6 +2326,7 @@ func initializePermissions() {
PermissionReadLicenseInformation,
PermissionManageLicenseInformation,
PermissionCreateCustomGroup,
PermissionManageOutgoingOAuthConnections,
}
TeamScopedPermissions := []*Permission{

View File

@ -342,6 +342,7 @@ func init() {
PermissionSysconsoleWriteIntegrationsCors.Id,
PermissionSysconsoleReadProductsBoards.Id,
PermissionSysconsoleWriteProductsBoards.Id,
PermissionManageOutgoingOAuthConnections.Id,
}
SystemCustomGroupAdminDefaultPermissions = []string{

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {IncomingWebhook, OutgoingWebhook, Command, OAuthApp} from '@mattermost/types/integrations';
import type {IncomingWebhook, OutgoingWebhook, Command, OAuthApp, OutgoingOAuthConnection} from '@mattermost/types/integrations';
import * as IntegrationActions from 'mattermost-redux/actions/integrations';
import {getProfilesByIds} from 'mattermost-redux/actions/users';
@ -137,3 +137,34 @@ export function loadProfilesForOAuthApps(apps: OAuthApp[]): ActionFuncAsync {
return {data: null};
};
}
export function loadOutgoingOAuthConnectionsAndProfiles(teamId: string, page = 0, perPage = DEFAULT_PAGE_SIZE): ActionFuncAsync<null> {
return async (dispatch) => {
const {data} = await dispatch(IntegrationActions.getOutgoingOAuthConnections(teamId, page, perPage));
if (data) {
dispatch(loadProfilesForOutgoingOAuthConnections(data));
}
return {data: null};
};
}
export function loadProfilesForOutgoingOAuthConnections(connections: OutgoingOAuthConnection[]): ActionFuncAsync<null> {
return async (dispatch, getState) => {
const state = getState();
const profilesToLoad: {[key: string]: boolean} = {};
for (let i = 0; i < connections.length; i++) {
const app = connections[i];
if (!getUser(state, app.creator_id)) {
profilesToLoad[app.creator_id] = true;
}
}
const list = Object.keys(profilesToLoad);
if (list.length === 0) {
return {data: null};
}
dispatch(getProfilesByIds(list));
return {data: null};
};
}

View File

@ -5179,6 +5179,19 @@ const AdminDefinition: AdminDefinitionType = {
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.INTEGRATION_MANAGEMENT)),
},
{
type: 'bool',
key: 'ServiceSettings.EnableOutgoingOAuthConnections',
label: defineMessage({id: 'admin.service.outgoingOAuthConnectionsTitle', defaultMessage: 'Enable Outgoing OAuth Connections: '}),
help_text: defineMessage({id: 'admin.service.outgoingOAuthConnectionsDesc', defaultMessage: 'When true, outgoing webhooks and slash commands will use set up oauth connections to authenticate with third party services. See <link>documentation</link> to learn more.'}),
help_text_values: {
link: (text: string) => (
<a href='https://mattermost.com/pl/outgoing-oauth-connections'>{text}</a>
),
},
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.INTEGRATION_MANAGEMENT)),
},
{
type: 'bool',
key: 'ServiceSettings.EnableCommands',

View File

@ -222,6 +222,9 @@ export default class PermissionsTree extends React.PureComponent<Props, State> {
if (config.EnableOAuthServiceProvider === 'true' && !integrationsGroup.permissions.includes(Permissions.MANAGE_OAUTH)) {
integrationsGroup.permissions.push(Permissions.MANAGE_OAUTH);
}
if (config.EnableOutgoingOAuthConnections === 'true' && !integrationsGroup.permissions.includes(Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS)) {
integrationsGroup.permissions.push(Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS);
}
if (config.EnableCommands === 'true' && !integrationsGroup.permissions.includes(Permissions.MANAGE_SLASH_COMMANDS)) {
integrationsGroup.permissions.push(Permissions.MANAGE_SLASH_COMMANDS);
}
@ -234,6 +237,7 @@ export default class PermissionsTree extends React.PureComponent<Props, State> {
if (config.EnableCustomEmoji === 'true' && !integrationsGroup.permissions.includes(Permissions.DELETE_OTHERS_EMOJIS)) {
integrationsGroup.permissions.push(Permissions.DELETE_OTHERS_EMOJIS);
}
if (config.EnableGuestAccounts === 'true' && !teamsGroup.permissions.includes(Permissions.INVITE_GUEST)) {
teamsGroup.permissions.push(Permissions.INVITE_GUEST);
}

View File

@ -615,4 +615,14 @@ export const permissionRolesStrings: Record<string, Record<string, MessageDescri
defaultMessage: 'Rename custom groups.',
},
}),
manage_outgoing_oauth_connections: defineMessages({
name: {
id: 'admin.permissions.permission.manage_outgoing_oauth_connections.name',
defaultMessage: 'Manage Outgoing OAuth Credentials',
},
description: {
id: 'admin.permissions.permission.manage_outgoing_oauth_connections.description',
defaultMessage: 'Create, edit, and delete outgoing OAuth credentials.',
},
}),
};

View File

@ -26,6 +26,9 @@ import EditOutgoingWebhook from 'components/integrations/edit_outgoing_webhook';
import InstalledIncomingWebhooks from 'components/integrations/installed_incoming_webhooks';
import InstalledOauthApps from 'components/integrations/installed_oauth_apps';
import InstalledOutgoingWebhooks from 'components/integrations/installed_outgoing_webhooks';
import AddOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/add_outgoing_oauth_connection';
import EditOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/edit_outgoing_oauth_connection';
import InstalledOutgoingOAuthConnections from 'components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connections';
import Pluggable from 'plugins/pluggable';
@ -76,6 +79,7 @@ type Props = {
enableOutgoingWebhooks: boolean;
enableCommands: boolean;
enableOAuthServiceProvider: boolean;
enableOutgoingOAuthConnections: boolean;
canCreateOrDeleteCustomEmoji: boolean;
canManageIntegrations: boolean;
}
@ -110,12 +114,12 @@ const BackstageController = (props: Props) => {
<Pluggable pluggableName='Root'/>
<BackstageSidebar
team={props.team}
user={props.user}
enableCustomEmoji={props.enableCustomEmoji}
enableIncomingWebhooks={props.enableIncomingWebhooks}
enableOutgoingWebhooks={props.enableOutgoingWebhooks}
enableCommands={props.enableCommands}
enableOAuthServiceProvider={props.enableOAuthServiceProvider}
enableOutgoingOAuthConnections={props.enableOutgoingOAuthConnections}
canCreateOrDeleteCustomEmoji={props.canCreateOrDeleteCustomEmoji}
canManageIntegrations={props.canManageIntegrations}
/>
@ -179,6 +183,24 @@ const BackstageController = (props: Props) => {
path={`${props.match.url}/oauth2-apps/edit`}
component={EditOauthApp}
/>
<BackstageRoute
extraProps={extraProps}
exact={true}
path={`${props.match.url}/outgoing-oauth2-connections`}
component={InstalledOutgoingOAuthConnections}
/>
<BackstageRoute
extraProps={extraProps}
exact={true}
path={`${props.match.url}/outgoing-oauth2-connections/add`}
component={AddOutgoingOAuthConnection}
/>
<BackstageRoute
extraProps={extraProps}
exact={true}
path={`${props.match.url}/outgoing-oauth2-connections/edit`}
component={EditOutgoingOAuthConnection}
/>
<BackstageRoute
extraProps={extraProps}
path={`${props.match.url}/confirm`}

View File

@ -16,7 +16,6 @@ describe('components/backstage/components/BackstageSidebar', () => {
id: 'team-id',
name: 'team_name',
}),
user: TestHelper.getUserMock({}),
enableCustomEmoji: false,
enableIncomingWebhooks: false,
enableOutgoingWebhooks: false,
@ -24,6 +23,7 @@ describe('components/backstage/components/BackstageSidebar', () => {
enableOAuthServiceProvider: false,
canCreateOrDeleteCustomEmoji: false,
canManageIntegrations: false,
enableOutgoingOAuthConnections: false,
};
describe('custom emoji', () => {
@ -146,6 +146,30 @@ describe('components/backstage/components/BackstageSidebar', () => {
});
});
describe('outgoing oauth connections', () => {
const testCases = [
{canManageIntegrations: false, enableOutgoingOAuthConnections: false, expectedResult: false},
{canManageIntegrations: false, enableOutgoingOAuthConnections: true, expectedResult: false},
{canManageIntegrations: true, enableOutgoingOAuthConnections: false, expectedResult: false},
{canManageIntegrations: true, enableOutgoingOAuthConnections: true, expectedResult: true},
];
testCases.forEach((testCase) => {
it(`when outgoing oauth connections is ${testCase.enableOutgoingOAuthConnections} and can manage integrations is ${testCase.canManageIntegrations}`, () => {
const props = {
...defaultProps,
enableOutgoingOAuthConnections: testCase.enableOutgoingOAuthConnections,
canManageIntegrations: testCase.canManageIntegrations,
};
const wrapper = shallow(
<BackstageSidebar {...props}/>,
);
expect(wrapper.find(BackstageCategory).find({name: 'outgoing-oauth2-connections'}).exists()).toBe(testCase.expectedResult);
});
});
});
describe('bots', () => {
const testCases = [
{canManageIntegrations: false, expectedResult: false},
@ -176,6 +200,7 @@ describe('components/backstage/components/BackstageSidebar', () => {
enableCommands: true,
enableOAuthServiceProvider: true,
canManageIntegrations: true,
enableOutgoingOAuthConnections: true,
};
const wrapper = shallow(
<BackstageSidebar {...props}/>,
@ -185,6 +210,7 @@ describe('components/backstage/components/BackstageSidebar', () => {
expect(wrapper.find(BackstageCategory).find({name: 'outgoing_webhooks'}).exists()).toBe(true);
expect(wrapper.find(BackstageCategory).find({name: 'commands'}).exists()).toBe(true);
expect(wrapper.find(BackstageCategory).find({name: 'oauth2-apps'}).exists()).toBe(true);
expect(wrapper.find(BackstageCategory).find({name: 'outgoing-oauth2-connections'}).exists()).toBe(true);
expect(wrapper.find(BackstageCategory).find({name: 'bots'}).exists()).toBe(true);
});
@ -195,6 +221,7 @@ describe('components/backstage/components/BackstageSidebar', () => {
enableOutgoingWebhooks: true,
enableCommands: true,
enableOAuthServiceProvider: true,
enableOutgoingOAuthConnections: true,
canManageIntegrations: false,
};
const wrapper = shallow(
@ -205,6 +232,7 @@ describe('components/backstage/components/BackstageSidebar', () => {
expect(wrapper.find(BackstageCategory).find({name: 'outgoing_webhooks'}).exists()).toBe(false);
expect(wrapper.find(BackstageCategory).find({name: 'commands'}).exists()).toBe(false);
expect(wrapper.find(BackstageCategory).find({name: 'oauth2-apps'}).exists()).toBe(false);
expect(wrapper.find(BackstageCategory).find({name: 'outgoing-oauth2-connections'}).exists()).toBe(false);
expect(wrapper.find(BackstageCategory).find({name: 'bots'}).exists()).toBe(false);
});
});

View File

@ -5,7 +5,6 @@ import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {Permissions} from 'mattermost-redux/constants';
@ -17,12 +16,12 @@ import BackstageSection from './backstage_section';
type Props = {
team: Team;
user: UserProfile;
enableCustomEmoji: boolean;
enableIncomingWebhooks: boolean;
enableOutgoingWebhooks: boolean;
enableCommands: boolean;
enableOAuthServiceProvider: boolean;
enableOutgoingOAuthConnections: boolean;
canCreateOrDeleteCustomEmoji: boolean;
canManageIntegrations: boolean;
}
@ -156,6 +155,28 @@ export default class BackstageSidebar extends React.PureComponent<Props> {
</SystemPermissionGate>
);
let outgoingOAuthConnections: JSX.Element | null = null;
if (this.props.enableOutgoingOAuthConnections) {
outgoingOAuthConnections = (
<TeamPermissionGate
permissions={[Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]}
teamId={this.props.team.id}
>
<BackstageSection
name='outgoing-oauth2-connections'
parentLink={'/' + this.props.team.name + '/integrations'}
title={
<FormattedMessage
id='backstage_sidebar.integrations.outgoingOauthConnections'
defaultMessage='Outgoing OAuth 2.0 Connections'
/>
}
id='outgoingOauthConnections'
/>
</TeamPermissionGate>
);
}
return (
<BackstageCategory
name='integrations'
@ -173,6 +194,7 @@ export default class BackstageSidebar extends React.PureComponent<Props> {
{commands}
{oauthApps}
{botAccounts}
{outgoingOAuthConnections}
</BackstageCategory>
);
}

View File

@ -26,6 +26,7 @@ function mapStateToProps(state: GlobalState) {
const enableOutgoingWebhooks = config.EnableOutgoingWebhooks === 'true';
const enableCommands = config.EnableCommands === 'true';
const enableOAuthServiceProvider = config.EnableOAuthServiceProvider === 'true';
const enableOutgoingOAuthConnections = config.EnableOutgoingOAuthConnections === 'true';
let canCreateOrDeleteCustomEmoji = (haveISystemPermission(state, {permission: Permissions.CREATE_EMOJIS}) || haveISystemPermission(state, {permission: Permissions.DELETE_EMOJIS}));
if (!canCreateOrDeleteCustomEmoji) {
@ -37,7 +38,7 @@ function mapStateToProps(state: GlobalState) {
}
}
const canManageTeamIntegrations = (haveITeamPermission(state, '', Permissions.MANAGE_SLASH_COMMANDS) || haveITeamPermission(state, '', Permissions.MANAGE_OAUTH) || haveITeamPermission(state, '', Permissions.MANAGE_INCOMING_WEBHOOKS) || haveITeamPermission(state, '', Permissions.MANAGE_OUTGOING_WEBHOOKS));
const canManageTeamIntegrations = (haveITeamPermission(state, team.id, Permissions.MANAGE_SLASH_COMMANDS) || haveITeamPermission(state, team.id, Permissions.MANAGE_OAUTH) || haveITeamPermission(state, team.id, Permissions.MANAGE_INCOMING_WEBHOOKS) || haveITeamPermission(state, team.id, Permissions.MANAGE_OUTGOING_WEBHOOKS));
const canManageSystemBots = (haveISystemPermission(state, {permission: Permissions.MANAGE_BOTS}) || haveISystemPermission(state, {permission: Permissions.MANAGE_OTHERS_BOTS}));
const canManageIntegrations = canManageTeamIntegrations || canManageSystemBots;
@ -50,6 +51,7 @@ function mapStateToProps(state: GlobalState) {
enableOutgoingWebhooks,
enableCommands,
enableOAuthServiceProvider,
enableOutgoingOAuthConnections,
canCreateOrDeleteCustomEmoji,
canManageIntegrations,
};

View File

@ -169,13 +169,9 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="url"
maxLength={1024}
<OAuthConnectionAudienceInput
onChange={[Function]}
placeholder="Must start with http:// or https://"
type="text"
value="https://google.com/command"
/>
<div
@ -186,6 +182,19 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
id="add_command.url.help"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="You can connect commands to <link>outgoing OAuth connections</link>."
id="add_command.outgoing_oauth_connections.help_text"
values={
Object {
"link": [Function],
}
}
/>
</div>
</div>
</div>
<div
@ -609,13 +618,9 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="url"
maxLength={1024}
<OAuthConnectionAudienceInput
onChange={[Function]}
placeholder="Must start with http:// or https://"
type="text"
value="https://google.com/command"
/>
<div
@ -626,6 +631,19 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
id="add_command.url.help"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="You can connect commands to <link>outgoing OAuth connections</link>."
id="add_command.outgoing_oauth_connections.help_text"
values={
Object {
"link": [Function],
}
}
/>
</div>
</div>
</div>
<div
@ -1049,13 +1067,9 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="url"
maxLength={1024}
<OAuthConnectionAudienceInput
onChange={[Function]}
placeholder="Must start with http:// or https://"
type="text"
value="https://google.com/command"
/>
<div
@ -1066,6 +1080,19 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
id="add_command.url.help"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="You can connect commands to <link>outgoing OAuth connections</link>."
id="add_command.outgoing_oauth_connections.help_text"
values={
Object {
"link": [Function],
}
}
/>
</div>
</div>
</div>
<div

View File

@ -10,18 +10,18 @@ exports[`components/integrations/InstalledCommand should call onDelete function
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
<div
className="item-details__name"
>
<strong>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
<div
className="item-actions"
>
@ -116,18 +116,18 @@ exports[`components/integrations/InstalledCommand should call onRegenToken funct
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
<div
className="item-details__name"
>
<strong>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
<div
className="item-actions"
>
@ -224,18 +224,18 @@ exports[`components/integrations/InstalledCommand should match snapshot 1`] = `
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
<div
className="item-details__name"
>
<strong>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<div
className="item-details__row"
@ -298,18 +298,18 @@ exports[`components/integrations/InstalledCommand should match snapshot, not aut
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
<div
className="item-details__name"
>
<strong>
command_display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger
</span>
</div>
<span
className="item-details__trigger"
>
- /trigger
</span>
</div>
<div
className="item-details__row"

View File

@ -17,6 +17,8 @@ import SpinnerButton from 'components/spinner_button';
import {Constants, DeveloperLinks} from 'utils/constants';
import * as Utils from 'utils/utils';
import OAuthConnectionAudienceInput from './outgoing_oauth_connections/oauth_connection_audience_input';
const REQUEST_POST = 'P';
const REQUEST_GET = 'G';
@ -65,7 +67,7 @@ type Props = {
intl: IntlShape;
}
type State= {
type State = {
saving: boolean;
clientError: null | JSX.Element | string;
trigger: string;
@ -87,7 +89,7 @@ export class AbstractCommand extends React.PureComponent<Props, State> {
this.state = this.getStateFromCommand(this.props.initialCommand || {});
}
getStateFromCommand = (command: Props['initialCommand']) => {
getStateFromCommand = (command: Props['initialCommand']): State => {
return {
displayName: command?.display_name ?? '',
description: command?.description ?? '',
@ -523,11 +525,7 @@ export class AbstractCommand extends React.PureComponent<Props, State> {
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='url'
type='text'
maxLength={1024}
className='form-control'
<OAuthConnectionAudienceInput
value={this.state.url}
onChange={this.updateUrl}
placeholder={this.props.intl.formatMessage({
@ -541,6 +539,17 @@ export class AbstractCommand extends React.PureComponent<Props, State> {
defaultMessage='Specify the callback URL to receive the HTTP POST or GET event request when the slash command is run.'
/>
</div>
<div className='form__help'>
<FormattedMessage
id={'add_command.outgoing_oauth_connections.help_text'}
defaultMessage={'You can connect commands to <link>outgoing OAuth connections</link>.'}
values={{
link: (text: string) => (
<a href='https://mattermost.com/pl/outgoing-oauth-connections'>{text}</a>
),
}}
/>
</div>
</div>
</div>
<div className='form-group'>

View File

@ -148,7 +148,6 @@ describe('components/integrations/AbstractOutgoingWebhook', () => {
const selector = wrapper.find('#triggerWhen');
selector.simulate('change', {target: {value: 1}});
console.log('selector: ', selector.debug());
expect(wrapper.state('triggerWhen')).toBe(1);
});

View File

@ -522,6 +522,7 @@ export default class Bot extends React.PureComponent<Props, State> {
defaultMessage='Delete'
/>
}
modalClass='integrations-backstage-modal'
show={this.state.confirmingId !== ''}
onConfirm={this.revokeTokenConfirmed}
onCancel={this.closeConfirm}

View File

@ -347,3 +347,114 @@ exports[`components/integrations/ConfirmIntegration should match snapshot, outgo
</div>
</div>
`;
exports[`components/integrations/ConfirmIntegration should match snapshot, outgoingOAuthConnections case 1`] = `
<div
className="backstage-content row"
>
<BackstageHeader>
<Link
to="/team_test/integrations/outgoing-oauth2-connections"
>
<MemoizedFormattedMessage
defaultMessage="Outgoing OAuth 2.0 Connections"
id="installed_outgoing_oauth_connections.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Add"
id="integrations.add"
/>
</BackstageHeader>
<div
className="backstage-form backstage-form__confirmation"
>
<h4
className="backstage-form__title"
id="formTitle"
>
<MemoizedFormattedMessage
defaultMessage="Setup Successful"
id="integrations.successful"
/>
</h4>
<p
key="add_outgoing_oauth_connection.doneHelp"
>
<MemoizedFormattedMessage
defaultMessage="Your Outgoing OAuth 2.0 Connection is set up. When a request is sent to one of the following Audience URLs, the Client ID and Client Secret will now be used to retrieve a token from the Token URL, before sending the integration request (details at <link>Outgoing OAuth 2.0 Connections</link>)."
id="add_outgoing_oauth_connection.doneHelp"
values={
Object {
"link": [Function],
}
}
/>
</p>
<p
key="add_outgoing_oauth_connection.clientId"
>
<FormattedMarkdownMessage
defaultMessage="**Client ID**: {id}"
id="add_outgoing_oauth_connection.clientId"
values={
Object {
"id": "someid",
}
}
/>
<br />
<FormattedMarkdownMessage
defaultMessage="**Client Secret**: \\\\*\\\\*\\\\*\\\\*\\\\*\\\\*\\\\*\\\\*"
id="add_outgoing_oauth_connection.clientSecret"
values={
Object {
"secret": "",
}
}
/>
</p>
<p
className="word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="**Token URL**: \`{url}\`"
id="add_outgoing_oauth_connection.token_url"
values={
Object {
"url": "https://tokenurl.com",
}
}
/>
</p>
<p
className="word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="**Audience URL(s)**: \`{url}\`"
id="add_outgoing_oauth_connection.audience_urls"
values={
Object {
"url": "https://myaudience.com",
}
}
/>
</p>
<div
className="backstage-form__footer"
>
<Link
className="btn btn-primary"
id="doneButton"
to="/team_test/integrations/outgoing-oauth2-connections"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Done"
id="integrations.done"
/>
</Link>
</div>
</div>
</div>
`;

View File

@ -5,7 +5,7 @@ import {shallow} from 'enzyme';
import React from 'react';
import type {Bot} from '@mattermost/types/bots';
import type {IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mattermost/types/integrations';
import type {IncomingWebhook, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook} from '@mattermost/types/integrations';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import ConfirmIntegration from 'components/integrations/confirm_integration/confirm_integration';
@ -43,6 +43,16 @@ describe('components/integrations/ConfirmIntegration', () => {
client_secret: '<==secret==>',
callback_urls: ['https://someCallback', 'https://anotherCallback'],
};
const outgoingOAuthConnection = {
id,
audiences: ['https://myaudience.com'],
client_id: 'someid',
client_secret: '',
grant_type: 'client_credentials',
name: 'My OAuth Connection',
oauth_token_url: 'https://tokenurl.com',
};
const userId = 'b5tpgt4iepf45jt768jz84djhd';
const bot = TestHelper.getBotMock({
user_id: userId,
@ -50,6 +60,7 @@ describe('components/integrations/ConfirmIntegration', () => {
});
const commands = {[id]: TestHelper.getCommandMock({id, token})};
const oauthApps = {[id]: oauthApp} as unknown as IDMappedObjects<OAuthApp>;
const outgoingOAuthConnections = {[id]: outgoingOAuthConnection} as unknown as IDMappedObjects<OutgoingOAuthConnection>;
const incomingHooks: IDMappedObjects<IncomingWebhook> = {[id]: TestHelper.getIncomingWebhookMock({id})};
const outgoingHooks = {[id]: {id, token}} as unknown as IDMappedObjects<OutgoingWebhook>;
const bots: Record<string, Bot> = {[userId]: bot};
@ -59,6 +70,7 @@ describe('components/integrations/ConfirmIntegration', () => {
location,
commands,
oauthApps,
outgoingOAuthConnections,
incomingHooks,
outgoingHooks,
bots,
@ -82,6 +94,14 @@ describe('components/integrations/ConfirmIntegration', () => {
expect(container.querySelector('.word-break--all')).toHaveTextContent('URL(s): https://someCallback, https://anotherCallback');
});
test('should match snapshot, outgoingOAuthConnections case', () => {
props.location.search = getSearchString('outgoing-oauth2-connections');
const wrapper = shallow(
<ConfirmIntegration {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, commands case', () => {
props.location.search = getSearchString('commands');
const wrapper = shallow(

View File

@ -6,7 +6,7 @@ import {FormattedMessage} from 'react-intl';
import {Link, useHistory} from 'react-router-dom';
import type {Bot} from '@mattermost/types/bots';
import type {Command, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mattermost/types/integrations';
import type {Command, IncomingWebhook, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
@ -26,9 +26,10 @@ type Props = {
incomingHooks: IDMappedObjects<IncomingWebhook>;
outgoingHooks: IDMappedObjects<OutgoingWebhook>;
bots: Record<string, Bot>;
outgoingOAuthConnections: Record<string, OutgoingOAuthConnection>;
}
const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, outgoingHooks, bots}: Props): JSX.Element | null => {
const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, outgoingHooks, bots, outgoingOAuthConnections}: Props): JSX.Element | null => {
const history = useHistory();
const type = (new URLSearchParams(location.search)).get('type') || '';
@ -56,6 +57,7 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks,
const incomingHook = incomingHooks[id];
const outgoingHook = outgoingHooks[id];
const oauthApp = oauthApps[id];
const outgoingOAuthConnection = outgoingOAuthConnections[id];
const bot = bots[id];
if (type === Constants.Integrations.COMMAND && command) {
@ -243,6 +245,95 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks,
/>
</p>
);
} else if (type === Constants.Integrations.OUTGOING_OAUTH_CONNECTIONS && outgoingOAuthConnection) {
const clientId = outgoingOAuthConnection.client_id;
const clientSecret = outgoingOAuthConnection.client_secret;
const username = outgoingOAuthConnection.credentials_username;
const password = outgoingOAuthConnection.credentials_password;
headerText = (
<FormattedMessage
id='installed_outgoing_oauth_connections.header'
defaultMessage='Outgoing OAuth 2.0 Connections'
/>
);
helpText = [];
helpText.push(
<p key='add_outgoing_oauth_connection.doneHelp'>
<FormattedMessage
id='add_outgoing_oauth_connection.doneHelp'
defaultMessage='Your Outgoing OAuth 2.0 Connection is set up. When a request is sent to one of the following Audience URLs, the Client ID and Client Secret will now be used to retrieve a token from the Token URL, before sending the integration request (details at <link>Outgoing OAuth 2.0 Connections</link>).'
values={{
link: (msg: string) => (
<ExternalLink
href={DeveloperLinks.SETUP_OAUTH2} // TODO: dev docs for outgoing oauth connections feature
location='confirm_integration'
>
{msg}
</ExternalLink>
),
}}
/>
</p>,
);
helpText.push(
<p key='add_outgoing_oauth_connection.clientId'>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.clientId'
defaultMessage='**Client ID**: {id}'
values={{id: clientId}}
/>
<br/>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.clientSecret'
defaultMessage='**Client Secret**: \*\*\*\*\*\*\*\*'
values={{secret: clientSecret}}
/>
</p>,
);
if (outgoingOAuthConnection.grant_type === 'password') {
helpText.push(
<p key='add_outgoing_oauth_connection.username'>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.username'
defaultMessage='**Username**: {username}'
values={{username}}
/>
<CopyText
idMessage='integrations.copy_username'
defaultMessage='Copy Username'
value={username || ''}
/>
<br/>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.password'
defaultMessage='**Password**: {password}'
values={{password}}
/>
</p>,
);
}
tokenText = (
<>
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.token_url'
defaultMessage='**Token URL**: `{url}`'
values={{url: outgoingOAuthConnection.oauth_token_url}}
/>
</p>
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_outgoing_oauth_connection.audience_urls'
defaultMessage='**Audience URL(s)**: `{url}`'
values={{url: outgoingOAuthConnection.audiences.join(', ')}}
/>
</p>
</>
);
} else if (type === Constants.Integrations.BOT && bot) {
const botToken = (new URLSearchParams(location.search)).get('token') || '';

View File

@ -4,7 +4,7 @@
import {connect} from 'react-redux';
import {getBotAccounts} from 'mattermost-redux/selectors/entities/bots';
import {getCommands, getOAuthApps, getIncomingHooks, getOutgoingHooks} from 'mattermost-redux/selectors/entities/integrations';
import {getCommands, getOAuthApps, getIncomingHooks, getOutgoingHooks, getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations';
import type {GlobalState} from 'types/store';
@ -17,6 +17,7 @@ function mapStateToProps(state: GlobalState) {
incomingHooks: getIncomingHooks(state),
outgoingHooks: getOutgoingHooks(state),
bots: getBotAccounts(state),
outgoingOAuthConnections: getOutgoingOAuthConnections(state),
};
}

View File

@ -14,6 +14,7 @@ const ModalId = 'delete_integration_confirm';
type Props = {
confirmButtonText?: React.ReactNode;
linkText?: React.ReactNode;
subtitleText?: React.ReactNode;
modalMessage?: React.ReactNode;
modalTitle?: React.ReactNode;
onDelete: () => void;
@ -25,7 +26,7 @@ export default function DeleteIntegrationLink(props: Props) {
confirmButtonText = (
<FormattedMessage
id='integrations.delete.confirm.button'
defaultMessage='Delete'
defaultMessage='Yes, delete it'
/>
),
linkText = (
@ -50,11 +51,22 @@ export default function DeleteIntegrationLink(props: Props) {
modalId: ModalId,
dialogProps: {
confirmButtonText,
confirmButtonClass: 'btn btn-danger',
modalClass: 'integrations-backstage-modal',
message: (
<div className='alert alert-warning'>
<WarningIcon additionalClassName='mr-1'/>
{props.modalMessage}
</div>
<>
{props.subtitleText && (
<p>
{props.subtitleText}
</p>
)}
<div className='alert alert-danger'>
<WarningIcon additionalClassName='mr-1'/>
<strong>
{props.modalMessage}
</strong>
</div>
</>
),
onConfirm: onDelete,
title: modalTitle,

View File

@ -15,7 +15,7 @@ exports[`components/integrations/EditCommand should have match renderExtra 1`] =
id="update_command.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@ -85,7 +85,7 @@ exports[`components/integrations/EditCommand should match snapshot 1`] = `
id="update_command.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}

View File

@ -157,6 +157,7 @@ export default class EditCommand extends React.PureComponent<Props, State> {
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
modalClass='integrations-backstage-modal'
show={this.state.showConfirmModal}
onConfirm={this.submitCommand}
onCancel={this.confirmModalDismissed}

View File

@ -65,7 +65,6 @@ type Props = {
};
type State = {
showConfirmModal: boolean;
serverError: string;
};
@ -76,7 +75,6 @@ export default class EditIncomingWebhook extends React.PureComponent<Props, Stat
super(props);
this.state = {
showConfirmModal: false,
serverError: '',
};
}

View File

@ -15,7 +15,7 @@ exports[`components/integrations/EditOAuthApp should have match renderExtra 1`]
id="update_oauth_app.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@ -82,7 +82,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot 1`] = `
id="update_oauth_app.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@ -158,7 +158,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot when EnableO
id="update_oauth_app.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@ -234,7 +234,7 @@ exports[`components/integrations/EditOAuthApp should match snapshot, loading 1`]
id="update_oauth_app.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}

View File

@ -127,6 +127,7 @@ export default class EditOAuthApp extends React.PureComponent<Props, State> {
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
modalClass='integrations-backstage-modal'
show={this.state.showConfirmModal}
onConfirm={this.submitOAuthApp}
onCancel={this.confirmModalDismissed}

View File

@ -15,7 +15,7 @@ exports[`components/integrations/EditOutgoingWebhook should have match renderExt
id="update_outgoing_webhook.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@ -92,7 +92,7 @@ exports[`components/integrations/EditOutgoingWebhook should match snapshot 1`] =
id="update_outgoing_webhook.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}
@ -191,7 +191,7 @@ exports[`components/integrations/EditOutgoingWebhook should match snapshot when
id="update_outgoing_webhook.question"
/>
}
modalClass=""
modalClass="integrations-backstage-modal"
onCancel={[Function]}
onConfirm={[Function]}
show={false}

View File

@ -163,6 +163,7 @@ export default class EditOutgoingWebhook extends React.PureComponent<Props, Stat
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
modalClass='integrations-backstage-modal'
show={this.state.showConfirmModal}
onConfirm={this.submitHook}
onCancel={this.confirmModalDismissed}

View File

@ -16,6 +16,7 @@ function mapStateToProps(state: GlobalState) {
const enableOutgoingWebhooks = config.EnableOutgoingWebhooks === 'true';
const enableCommands = config.EnableCommands === 'true';
const enableOAuthServiceProvider = config.EnableOAuthServiceProvider === 'true';
const enableOutgoingOAuthConnections = config.EnableOutgoingOAuthConnections === 'true';
return {
siteName,
@ -23,6 +24,7 @@ function mapStateToProps(state: GlobalState) {
enableOutgoingWebhooks,
enableCommands,
enableOAuthServiceProvider,
enableOutgoingOAuthConnections,
};
}

View File

@ -149,14 +149,14 @@ export default class InstalledCommand extends React.PureComponent<Props> {
<div className='backstage-list__item'>
<div className='item-details'>
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<div>
<strong className='item-details__name'>
<div className='item-details__name'>
<strong>
{name}
</strong>
<span className='item-details__trigger'>
{trigger}
</span>
</div>
<span className='item-details__trigger'>
{trigger}
</span>
{actions}
</div>
{description}

View File

@ -15,6 +15,7 @@ import TeamPermissionGate from 'components/permissions_gates/team_permission_gat
import BotAccountsIcon from 'images/bot_default_icon.png';
import IncomingWebhookIcon from 'images/incoming_webhook.jpg';
import OAuthIcon from 'images/oauth_icon.png';
import OutgoingOAuthConnectionsIcon from 'images/outgoing_oauth_connection.png';
import OutgoingWebhookIcon from 'images/outgoing_webhook.jpg';
import SlashCommandIcon from 'images/slash_command_icon.jpg';
import * as Utils from 'utils/utils';
@ -27,6 +28,7 @@ type Props = {
enableOutgoingWebhooks: boolean;
enableCommands: boolean;
enableOAuthServiceProvider: boolean;
enableOutgoingOAuthConnections: boolean;
team: Team;
}
@ -154,6 +156,34 @@ export default class Integrations extends React.PureComponent <Props> {
);
}
if (this.props.enableOutgoingOAuthConnections) {
options.push(
<TeamPermissionGate
teamId={this.props.team.id}
permissions={[Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]}
key='outgoingOAuthConnectionsPermission'
>
<IntegrationOption
key='outgoingOAuthConnections'
image={OutgoingOAuthConnectionsIcon}
title={
<FormattedMessage
id='integrations.outgoingOAuthConnections.title'
defaultMessage='Outgoing OAuth Connections'
/>
}
description={
<FormattedMessage
id='integrations.outgoingOAuthConnections.description'
defaultMessage='Outgoing OAuth Connections allow custom integrations to communicate to external systems'
/>
}
link={'/' + this.props.team.name + '/integrations/outgoing-oauth2-connections'}
/>
</TeamPermissionGate>,
);
}
options.push(
<SystemPermissionGate
permissions={['manage_bots']}

View File

@ -0,0 +1,559 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AddOutgoingOAuthConnection should match snapshot 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<AddOutgoingOAuthConnection
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "dbcxd9wpzpbpfp8pad78xj12pr",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
>
<AbstractOutgoingOAuthConnection
footer={
Object {
"defaultMessage": "Save",
"id": "add_outgoing_oauth_connection.save",
}
}
header={
Object {
"defaultMessage": "Add",
"id": "add_outgoing_oauth_connection.add",
}
}
loading={
Object {
"defaultMessage": "Saving...",
"id": "add_outgoing_oauth_connection.saving",
}
}
serverError=""
submitAction={[Function]}
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "dbcxd9wpzpbpfp8pad78xj12pr",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
>
<div
className="backstage-content"
>
<BackstageHeader>
<div
className="backstage-header"
>
<h1>
<Link
to="/test/integrations/outgoing-oauth2-connections"
>
<LinkAnchor
href="/test/integrations/outgoing-oauth2-connections"
navigate={[Function]}
>
<a
href="/test/integrations/outgoing-oauth2-connections"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Outgoing OAuth Connections"
id="add_outgoing_oauth_connection.header"
>
<span>
Outgoing OAuth Connections
</span>
</FormattedMessage>
</a>
</LinkAnchor>
</Link>
<span
className="backstage-header__divider"
key="divider1"
>
<i
className="fa fa-angle-right"
title="Breadcrumb Icon"
/>
</span>
<FormattedMessage
defaultMessage="Add"
id="add_outgoing_oauth_connection.add"
>
<span>
Add
</span>
</FormattedMessage>
</h1>
</div>
</BackstageHeader>
<div
className="backstage-form"
>
<form
className="form-horizontal"
>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="name"
>
<FormattedMessage
defaultMessage="Name"
id="add_outgoing_oauth_connection.name.label"
>
<span>
Name
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="name"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="Specify the name for your OAuth connection."
id="add_outgoing_oauth_connection.name.help"
>
<span>
Specify the name for your OAuth connection.
</span>
</FormattedMessage>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="client_id"
>
<FormattedMessage
defaultMessage="Client ID"
id="add_outgoing_oauth_connection.client_id.label"
>
<span>
Client ID
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
autoComplete="off"
className="form-control"
id="client_id"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="Specify the Client ID for your OAuth connection."
id="add_outgoing_oauth_connection.client_id.help"
>
<span>
Specify the Client ID for your OAuth connection.
</span>
</FormattedMessage>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="client_secret"
>
<FormattedMessage
defaultMessage="Client Secret"
id="add_outgoing_oauth_connection.client_secret.label"
>
<span>
Client Secret
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
autoComplete="off"
className="form-control"
id="client_secret"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="Specify the Client Secret for your OAuth connection."
id="add_outgoing_oauth_connection.client_secret.help"
>
<span>
Specify the Client Secret for your OAuth connection.
</span>
</FormattedMessage>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="oauth_token_url"
>
<FormattedMessage
defaultMessage="OAuth Token URL"
id="add_outgoing_oauth_connection.oauth_token_url.label"
>
<span>
OAuth Token URL
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="token_url"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="Specify the OAuth Token URL for your OAuth connection."
id="add_outgoing_oauth_connection.oauth_token_url.help"
>
<span>
Specify the OAuth Token URL for your OAuth connection.
</span>
</FormattedMessage>
</div>
<div
className="outgoing-oauth-connection-validate-button-container"
>
<ValidateButton
onClick={[Function]}
setUnvalidated={[Function]}
status="initial"
>
<button
className="btn btn-tertiary btn-sm"
id="validateConnection"
onClick={[Function]}
type="button"
>
<FormattedMessage
defaultMessage="Validate Connection"
id="add_outgoing_oauth_connection.validate"
>
<span>
Validate Connection
</span>
</FormattedMessage>
</button>
</ValidateButton>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="audienceUrls"
>
<FormattedMessage
defaultMessage="Audience URLs (One Per Line)"
id="add_outgoing_oauth_connection.audienceUrls.label"
>
<span>
Audience URLs (One Per Line)
</span>
</FormattedMessage>
</label>
<div
className="col-md-5 col-sm-8"
>
<textarea
className="form-control"
id="audienceUrls"
onChange={[Function]}
rows={3}
value=""
/>
<div
className="form__help"
>
<FormattedMessage
defaultMessage="The URLs which will receive requests with the OAuth token, e.g. your custom slash command handler endpoint. Must be a valid URL and start with http:// or https://."
id="add_outgoing_oauth_connection.audienceUrls.help"
>
<span>
The URLs which will receive requests with the OAuth token, e.g. your custom slash command handler endpoint. Must be a valid URL and start with http:// or https://.
</span>
</FormattedMessage>
</div>
</div>
</div>
<div
className="backstage-form__footer"
>
<FormError
error={null}
errors={
Array [
"",
"",
]
}
type="backstage"
/>
<Link
className="btn btn-tertiary"
to="/test/integrations/outgoing-oauth2-connections"
>
<LinkAnchor
className="btn btn-tertiary"
href="/test/integrations/outgoing-oauth2-connections"
navigate={[Function]}
>
<a
className="btn btn-tertiary"
href="/test/integrations/outgoing-oauth2-connections"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Cancel"
id="add_outgoing_oauth_connection.cancel"
>
<span>
Cancel
</span>
</FormattedMessage>
</a>
</LinkAnchor>
</Link>
<Memo(SpinnerButton)
className="btn btn-primary"
id="saveConnection"
onClick={[Function]}
spinning={false}
spinningText="Saving..."
type="submit"
>
<button
className="btn btn-primary"
disabled={false}
id="saveConnection"
onClick={[Function]}
type="submit"
>
<Memo(LoadingWrapper)
loading={false}
text="Saving..."
>
<FormattedMessage
defaultMessage="Save"
id="add_outgoing_oauth_connection.save"
>
<span>
Save
</span>
</FormattedMessage>
</Memo(LoadingWrapper)>
</button>
</Memo(SpinnerButton)>
</div>
</form>
</div>
<div
className="outgoing-oauth-connections-docs-link"
>
<FormattedMessage
defaultMessage="Get help with <link>configuring outgoing OAuth connections</link>."
id="add_outgoing_oauth_connection.documentation_link"
values={
Object {
"link": [Function],
}
}
>
<span>
Get help with
<a
href="https://mattermost.com/pl/outgoing-oauth-connections"
key=".$.1"
>
configuring outgoing OAuth connections
</a>
.
</span>
</FormattedMessage>
</div>
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText="Save anyway"
message="This connection has not been validated, Do you want to save anyway?"
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
onExited={[Function]}
show={false}
title="Save Outgoing OAuth Connection"
>
<Modal
animation={true}
aria-describedby="confirmModalBody"
aria-labelledby="confirmModalLabel"
aria-modal={true}
autoFocus={true}
backdrop={true}
bsClass="modal"
className="modal-confirm "
dialogClassName="a11y__modal"
dialogComponentClass={[Function]}
enforceFocus={true}
id="confirmModal"
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={false}
>
<Modal
autoFocus={true}
backdrop={true}
backdropClassName="modal-backdrop"
backdropTransition={[Function]}
containerClassName="modal-open"
enforceFocus={true}
keyboard={true}
manager={
ModalManager {
"add": [Function],
"containers": Array [],
"data": Array [],
"handleContainerOverflow": true,
"hideSiblingNodes": true,
"isTopModal": [Function],
"modals": Array [],
"remove": [Function],
}
}
onEntering={[Function]}
onExited={[Function]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
show={false}
transition={[Function]}
/>
</Modal>
</ConfirmModal>
</div>
</AbstractOutgoingOAuthConnection>
</AddOutgoingOAuthConnection>
</Provider>
</Router>
</BrowserRouter>
`;

View File

@ -0,0 +1,137 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledOutgoingOAuthConnection should match snapshot 1`] = `
<div
className="backstage-list__item"
>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<strong
className="item-details__name"
>
My OAuth Connection
</strong>
<div
className="item-actions"
>
<Link
to="/team_name/integrations/outgoing-oauth2-connections/edit?id=someid"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deleting this connection will break any integrations using it"
id="installed_outgoing_oauth_connections.delete.wanring"
/>
}
onDelete={[Function]}
subtitleText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Are you sure you want to delete {connectionName}?"
id="installed_outgoing_oauth_connections.delete.confirm"
values={
Object {
"connectionName": <strong>
My OAuth Connection
</strong>,
}
}
/>
}
/>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<FormattedMarkdownMessage
defaultMessage="Client ID: **{clientId}**"
id="installed_integrations.client_id"
values={
Object {
"clientId": "someid",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Client Secret: ********"
id="installed_outgoing_oauth_connections.client_secret"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="Audience URLs: {urls}"
id="installed_integrations.audience_urls"
values={
Object {
"urls": "https://myaudience.com",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="Token URL: {url}"
id="installed_integrations.token_url"
values={
Object {
"url": "https://tokenurl.com",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1501365458934,
"creator": "somename",
}
}
/>
</span>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,260 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledOutgoingOAuthConnections should match snapshot 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<InstalledOutgoingOAuthConnections
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
>
<BackstageList
addButtonId="addOutgoingOauthConnection"
addLink="/test/integrations/outgoing-oauth2-connections/add"
addText="Add Outgoing OAuth Connection"
emptyText={
<Memo(MemoizedFormattedMessage)
defaultMessage="No Outgoing OAuth Connections found"
id="installed_outgoing_oauth_connections.empty"
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
defaultMessage="No Outgoing OAuth Connections match {searchTerm}"
id="installed_outgoing_oauth_connections.emptySearch"
/>
}
header={
<Memo(MemoizedFormattedMessage)
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.header"
/>
}
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Create {outgoingOauthConnections} to securely integrate bots and third-party apps with Mattermost."
id="installed_outgoing_oauth_connections.help"
values={
Object {
"outgoingOauthConnections": <ExternalLink
href="https://mattermost.com/pl/setup-oauth-2.0"
location="installed_outgoing_oauth_connections"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
/>
</ExternalLink>,
}
}
/>
}
loading={true}
searchPlaceholder="Search Outgoing OAuth Connections"
>
<div
className="backstage-content"
>
<div
className="backstage-header"
>
<h1>
<FormattedMessage
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.header"
>
<span>
Outgoing OAuth Connections
</span>
</FormattedMessage>
</h1>
<Link
className="add-link"
to="/test/integrations/outgoing-oauth2-connections/add"
>
<LinkAnchor
className="add-link"
href="/test/integrations/outgoing-oauth2-connections/add"
navigate={[Function]}
>
<a
className="add-link"
href="/test/integrations/outgoing-oauth2-connections/add"
onClick={[Function]}
>
<button
className="btn btn-primary"
id="addOutgoingOauthConnection"
type="button"
>
<span>
Add Outgoing OAuth Connection
</span>
</button>
</a>
</LinkAnchor>
</Link>
</div>
<div
className="backstage-filters"
>
<div
className="backstage-filter__search"
>
<SearchIcon>
<i
className="fa fa-search"
title="Search Icon"
/>
</SearchIcon>
<input
className="form-control"
id="searchInput"
onChange={[Function]}
placeholder="Search Outgoing OAuth Connections"
style={
Object {
"flexGrow": 0,
"flexShrink": 0,
}
}
type="search"
value=""
/>
</div>
</div>
<span
className="backstage-list__help"
>
<FormattedMessage
defaultMessage="Create {outgoingOauthConnections} to securely integrate bots and third-party apps with Mattermost."
id="installed_outgoing_oauth_connections.help"
values={
Object {
"outgoingOauthConnections": <ExternalLink
href="https://mattermost.com/pl/setup-oauth-2.0"
location="installed_outgoing_oauth_connections"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
/>
</ExternalLink>,
}
}
>
<span>
Create
<ExternalLink
href="https://mattermost.com/pl/setup-oauth-2.0"
key=".$.1"
location="installed_outgoing_oauth_connections"
>
<a
href="https://mattermost.com/pl/setup-oauth-2.0?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=installed_outgoing_oauth_connections&uid=current_user_id&sid="
location="installed_outgoing_oauth_connections"
onClick={[Function]}
rel="noopener noreferrer"
target="_blank"
>
<FormattedMessage
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
>
<span>
Outgoing OAuth Connections
</span>
</FormattedMessage>
</a>
</ExternalLink>
to securely integrate bots and third-party apps with Mattermost.
</span>
</FormattedMessage>
</span>
<div
className="backstage-list"
>
<LoadingScreen>
<div
className="loading-screen"
style={
Object {
"position": "relative",
}
}
>
<div
className="loading__content"
>
<p>
Loading
</p>
<div
className="round round-1"
/>
<div
className="round round-2"
/>
<div
className="round round-3"
/>
</div>
</div>
</LoadingScreen>
</div>
</div>
</BackstageList>
</InstalledOutgoingOAuthConnections>
</Provider>
</Router>
</BrowserRouter>
`;

View File

@ -0,0 +1,417 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot when an audience url with a wildcard is configured, and typed in value starts with configured audience url 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value="https://aud.com/api/it_matches"
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value="https://aud.com/api/it_matches"
/>
<div
className="outgoing-oauth-audience-match-message-container"
>
<span>
<InformationOutlineIcon
size={20}
>
<svg
fill="currentColor"
height={20}
version="1.1"
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"
/>
</svg>
</InformationOutlineIcon>
</span>
<span
className="outgoing-oauth-audience-match-message"
>
<FormattedMessage
defaultMessage="Not linked to an OAuth connection"
id="add_outgoing_oauth_connection.not_connected"
>
<span>
Not linked to an OAuth connection
</span>
</FormattedMessage>
</span>
</div>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot when typed in value does not have an exact match 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value="https://aud.com/api/no_match"
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value="https://aud.com/api/no_match"
/>
<div
className="outgoing-oauth-audience-match-message-container"
>
<span>
<InformationOutlineIcon
size={20}
>
<svg
fill="currentColor"
height={20}
version="1.1"
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"
/>
</svg>
</InformationOutlineIcon>
</span>
<span
className="outgoing-oauth-audience-match-message"
>
<FormattedMessage
defaultMessage="Not linked to an OAuth connection"
id="add_outgoing_oauth_connection.not_connected"
>
<span>
Not linked to an OAuth connection
</span>
</FormattedMessage>
</span>
</div>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot when typed in value matches a configured audience 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value="https://aud.com/api"
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value="https://aud.com/api"
/>
<div
className="outgoing-oauth-audience-match-message-container"
>
<span>
<InformationOutlineIcon
size={20}
>
<svg
fill="currentColor"
height={20}
version="1.1"
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"
/>
</svg>
</InformationOutlineIcon>
</span>
<span
className="outgoing-oauth-audience-match-message"
>
<FormattedMessage
defaultMessage="Not linked to an OAuth connection"
id="add_outgoing_oauth_connection.not_connected"
>
<span>
Not linked to an OAuth connection
</span>
</FormattedMessage>
</span>
</div>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot with existing connections 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value=""
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value=""
/>
<div
className="outgoing-oauth-audience-match-message-container"
>
<span>
<InformationOutlineIcon
size={20}
>
<svg
fill="currentColor"
height={20}
version="1.1"
viewBox="0 0 24 24"
width={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"
/>
</svg>
</InformationOutlineIcon>
</span>
<span
className="outgoing-oauth-audience-match-message"
>
<FormattedMessage
defaultMessage="Not linked to an OAuth connection"
id="add_outgoing_oauth_connection.not_connected"
>
<span>
Not linked to an OAuth connection
</span>
</FormattedMessage>
</span>
</div>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;
exports[`components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput should match snapshot with no existing connections 1`] = `
<BrowserRouter>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"createHref": [Function],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"pathname": "undefinedundefined",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<Provider
store={
Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
}
}
>
<OAuthConnectionAudienceInput
onChange={[MockFunction]}
placeholder=""
value=""
>
<input
autoComplete="off"
className="form-control"
id="url"
maxLength={1024}
onChange={[Function]}
placeholder=""
value=""
/>
</OAuthConnectionAudienceInput>
</Provider>
</Router>
</BrowserRouter>
`;

View File

@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {act} from 'react-dom/test-utils';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {Permissions} from 'mattermost-redux/constants';
import AbstractOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/abstract_outgoing_oauth_connection';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/AbstractOutgoingOAuthConnection', () => {
const header = {id: 'Header', defaultMessage: 'Header'};
const footer = {id: 'Footer', defaultMessage: 'Footer'};
const loading = {id: 'Loading', defaultMessage: 'Loading'};
const initialConnection: OutgoingOAuthConnection = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testConnection',
client_secret: '',
client_id: 'clientid',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
grant_type: 'client_credentials',
oauth_token_url: 'https://token.com',
audiences: ['https://aud.com'],
};
const outgoingOAuthConnections: Record<string, OutgoingOAuthConnection> = {
facxd9wpzpbpfp8pad78xj75pr: initialConnection,
};
const state = {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
integrations: {
outgoingOAuthConnections,
},
},
};
const team = TestHelper.getTeamMock({name: 'test', id: initialConnection.id});
const baseProps: React.ComponentProps<typeof AbstractOutgoingOAuthConnection> = {
team,
header,
footer,
loading,
renderExtra: <div>{'renderExtra'}</div>,
serverError: '',
initialConnection,
submitAction: jest.fn(),
};
test('should match snapshot', () => {
const props = {...baseProps};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<AbstractOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, displays client error', () => {
const submitAction = jest.fn().mockResolvedValue({data: true});
const newServerError = 'serverError';
const props = {...baseProps, serverError: newServerError, submitAction};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<AbstractOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
wrapper.find('#audienceUrls').simulate('change', {target: {value: ''}});
wrapper.find('button.btn-primary').simulate('click', {preventDefault() {
return jest.fn();
}});
expect(submitAction).not.toBeCalled();
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('FormError').exists()).toBe(true);
});
test('should call action function', async () => {
const submitAction = jest.fn().mockResolvedValue({data: true});
const props = {...baseProps, submitAction};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<AbstractOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
await act(async () => {
wrapper.find('#name').simulate('change', {target: {value: 'name'}});
wrapper.find('button.btn-primary').simulate('click', {preventDefault() {
return jest.fn();
}});
expect(submitAction).toBeCalled();
});
});
});

View File

@ -0,0 +1,632 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChangeEvent, FormEvent} from 'react';
import React, {useMemo, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {MessageDescriptor} from 'react-intl';
import {useDispatch} from 'react-redux';
import {Link} from 'react-router-dom';
import {AlertOutlineIcon, CheckCircleOutlineIcon} from '@mattermost/compass-icons/components';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import {validateOutgoingOAuthConnection} from 'mattermost-redux/actions/integrations';
import BackstageHeader from 'components/backstage/components/backstage_header';
import ConfirmModal from 'components/confirm_modal';
import FormError from 'components/form_error';
import SpinnerButton from 'components/spinner_button';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
type Props = {
team: Team;
header: MessageDescriptor;
footer: MessageDescriptor;
loading: MessageDescriptor;
renderExtra?: JSX.Element;
serverError: string;
initialConnection?: OutgoingOAuthConnection;
submitAction: (connection: OutgoingOAuthConnection) => Promise<void>;
}
type State = {
name: string;
oauthTokenUrl: string;
grantType: OutgoingOAuthConnection['grant_type'];
clientId: string;
clientSecret: string;
audienceUrls: string;
};
enum ValidationStatus {
INITIAL = 'initial',
DIRTY = 'dirty',
VALIDATING = 'validating',
VALIDATED = 'validated',
ERROR = 'error',
}
const useOutgoingOAuthForm = (connection: OutgoingOAuthConnection): [State, (state: Partial<State>) => void] => {
const initialState: State = {
name: connection.name || '',
audienceUrls: connection.audiences ? connection.audiences.join('\n') : '',
oauthTokenUrl: connection.oauth_token_url || '',
clientId: connection.client_id || '',
clientSecret: connection.client_secret || '',
grantType: 'client_credentials',
};
const [state, setState] = useState(initialState);
return useMemo(() => [state, (newState: Partial<State>) => {
setState((oldState) => ({...oldState, ...newState}));
}], [state]);
};
const initialState: OutgoingOAuthConnection = {
id: '',
name: '',
creator_id: '',
create_at: 0,
update_at: 0,
client_id: '',
client_secret: '',
oauth_token_url: '',
grant_type: 'client_credentials',
audiences: [],
};
export default function AbstractOutgoingOAuthConnection(props: Props) {
const [formState, setFormState] = useOutgoingOAuthForm(props.initialConnection || initialState);
const [storedError, setError] = useState<React.ReactNode>('');
const [validationError, setValidationError] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [validationStatus, setValidationStatus] = useState<ValidationStatus>(ValidationStatus.INITIAL);
const [isEditingSecret, setIsEditingSecret] = useState(false);
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
const intl = useIntl();
const dispatch = useDispatch();
const isNewConnection = !props.initialConnection;
const parseForm = (requireAudienceUrl: boolean): OutgoingOAuthConnection | undefined => {
if (!formState.name) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.name.required'
defaultMessage='Name for the OAuth connection is required.'
/>,
);
return undefined;
}
if (!formState.clientId) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.client_id.required'
defaultMessage='Client Id for the OAuth connection is required.'
/>,
);
return undefined;
}
if ((isNewConnection || isEditingSecret) && !formState.clientSecret) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.client_secret.required'
defaultMessage='Client Secret for the OAuth connection is required.'
/>,
);
return undefined;
}
if (!formState.grantType) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.grant_type.required'
defaultMessage='Grant Type for the OAuth connection is required.'
/>,
);
return undefined;
}
if (!formState.oauthTokenUrl) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.oauth_token_url.required'
defaultMessage='OAuth Token URL for the OAuth connection is required.'
/>,
);
return undefined;
}
const audienceUrls = [];
for (let audienceUrl of formState.audienceUrls.split('\n')) {
audienceUrl = audienceUrl.trim();
if (audienceUrl.length > 0) {
audienceUrls.push(audienceUrl);
}
}
if (requireAudienceUrl && audienceUrls.length === 0) {
setIsSubmitting(false);
setError(
<FormattedMessage
id='add_outgoing_oauth_connection.audienceUrls.required'
defaultMessage='One or more audience URLs are required.'
/>,
);
return undefined;
}
const connection = {
name: formState.name,
audiences: audienceUrls,
client_id: formState.clientId,
client_secret: formState.clientSecret,
grant_type: formState.grantType,
oauth_token_url: formState.oauthTokenUrl,
} as OutgoingOAuthConnection;
return connection;
};
const showSkipValidateModal = () => {
setIsValidationModalOpen(true);
};
const hideSkipValidateModal = () => {
setIsValidationModalOpen(false);
};
const handleSubmitFromButton = (e: FormEvent) => {
e.preventDefault();
handleSubmit();
};
const handleSubmit = () => {
if (isSubmitting) {
return;
}
const connection = parseForm(true);
if (!connection) {
return;
}
setError('');
if (validationStatus !== ValidationStatus.VALIDATED && !(!isNewConnection && validationStatus === ValidationStatus.INITIAL)) {
if (!isValidationModalOpen) {
showSkipValidateModal();
return;
}
}
setIsSubmitting(true);
const res = props.submitAction(connection);
res.then(() => setIsSubmitting(false));
};
const handleValidate = async (e: FormEvent) => {
e.preventDefault();
if (validationStatus === ValidationStatus.VALIDATING) {
return;
}
setError('');
setValidationStatus(ValidationStatus.VALIDATING);
const connection = parseForm(false);
if (!connection) {
// Defer to the form validation error
setValidationStatus(ValidationStatus.INITIAL);
return;
}
if (props.initialConnection?.id) {
connection.id = props.initialConnection.id;
}
const {error} = await dispatch(validateOutgoingOAuthConnection(props.team.id, connection));
if (error) {
setValidationStatus(ValidationStatus.ERROR);
setValidationError(error.message);
} else {
setValidationStatus(ValidationStatus.VALIDATED);
}
};
const setUnvalidated = (e?: React.FormEvent) => {
e?.preventDefault();
if (validationStatus !== ValidationStatus.DIRTY) {
setValidationStatus(ValidationStatus.DIRTY);
}
if (validationError) {
setValidationError('');
}
};
const updateName = (e: ChangeEvent<HTMLInputElement>) => {
setFormState({
name: e.target.value,
});
};
const updateClientId = (e: ChangeEvent<HTMLInputElement>) => {
setUnvalidated();
setFormState({
clientId: e.target.value,
});
};
const updateClientSecret = (e: ChangeEvent<HTMLInputElement>) => {
setUnvalidated();
setFormState({
clientSecret: e.target.value,
});
};
const updateOAuthTokenURL = (e: ChangeEvent<HTMLInputElement>) => {
setUnvalidated();
setFormState({
oauthTokenUrl: e.target.value,
});
};
const updateAudienceUrls = (e: ChangeEvent<HTMLTextAreaElement>) => {
setFormState({
audienceUrls: e.target.value,
});
};
const startEditingClientSecret = () => {
setIsEditingSecret(true);
};
const headerToRender = props.header;
const footerToRender = props.footer;
let clientSecretSection = (
<input
id='client_secret'
type='text'
autoComplete='off'
className='form-control'
value={formState.clientSecret}
onChange={updateClientSecret}
/>
);
if (!isNewConnection && !isEditingSecret) {
clientSecretSection = (
<>
<input
id='client_secret'
disabled={true}
autoComplete='off'
type='text'
className='form-control disabled'
value={'•'.repeat(40)}
/>
<span
onClick={startEditingClientSecret}
className='outgoing-oauth-connections-edit-secret'
>
<i className='icon icon-pencil-outline'/>
</span>
</>
);
}
return (
<div className='backstage-content'>
<BackstageHeader>
<Link to={`/${props.team.name}/integrations/outgoing-oauth2-connections`}>
<FormattedMessage
id='add_outgoing_oauth_connection.header'
defaultMessage='Outgoing OAuth Connections'
/>
</Link>
<FormattedMessage
id={headerToRender.id}
defaultMessage={headerToRender.defaultMessage}
/>
</BackstageHeader>
<div className='backstage-form'>
<form className='form-horizontal'>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='name'
>
<FormattedMessage
id='add_outgoing_oauth_connection.name.label'
defaultMessage='Name'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='name'
type='text'
className='form-control'
value={formState.name}
onChange={updateName}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.name.help'
defaultMessage='Specify the name for your OAuth connection.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='client_id'
>
<FormattedMessage
id='add_outgoing_oauth_connection.client_id.label'
defaultMessage='Client ID'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='client_id'
type='text'
autoComplete='off'
className='form-control'
value={formState.clientId}
onChange={updateClientId}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.client_id.help'
defaultMessage='Specify the Client ID for your OAuth connection.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='client_secret'
>
<FormattedMessage
id='add_outgoing_oauth_connection.client_secret.label'
defaultMessage='Client Secret'
/>
</label>
<div className='col-md-5 col-sm-8'>
{clientSecretSection}
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.client_secret.help'
defaultMessage='Specify the Client Secret for your OAuth connection.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='oauth_token_url'
>
<FormattedMessage
id='add_outgoing_oauth_connection.oauth_token_url.label'
defaultMessage='OAuth Token URL'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='token_url'
type='text'
className='form-control'
value={formState.oauthTokenUrl}
onChange={updateOAuthTokenURL}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.oauth_token_url.help'
defaultMessage='Specify the OAuth Token URL for your OAuth connection.'
/>
</div>
<div className='outgoing-oauth-connection-validate-button-container'>
<ValidateButton
onClick={handleValidate}
setUnvalidated={setUnvalidated}
status={validationStatus}
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='audienceUrls'
>
<FormattedMessage
id='add_outgoing_oauth_connection.audienceUrls.label'
defaultMessage='Audience URLs (One Per Line)'
/>
</label>
<div className='col-md-5 col-sm-8'>
<textarea
id='audienceUrls'
rows={3}
className='form-control'
value={formState.audienceUrls}
onChange={updateAudienceUrls}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_oauth_connection.audienceUrls.help'
defaultMessage='The URLs which will receive requests with the OAuth token, e.g. your custom slash command handler endpoint. Must be a valid URL and start with http:// or https://.'
/>
</div>
</div>
</div>
<div className='backstage-form__footer'>
<FormError
type='backstage'
errors={[props.serverError, storedError]}
/>
<Link
className='btn btn-tertiary'
to={`/${props.team.name}/integrations/outgoing-oauth2-connections`}
>
<FormattedMessage
id='add_outgoing_oauth_connection.cancel'
defaultMessage='Cancel'
/>
</Link>
<SpinnerButton
className='btn btn-primary'
type='submit'
spinning={isSubmitting}
spinningText={intl.formatMessage(props.loading)}
onClick={handleSubmitFromButton}
id='saveConnection'
>
<FormattedMessage
id={footerToRender.id}
defaultMessage={footerToRender.defaultMessage}
/>
</SpinnerButton>
{props.renderExtra}
</div>
</form>
</div>
<div className='outgoing-oauth-connections-docs-link'>
<FormattedMessage
id={'add_outgoing_oauth_connection.documentation_link'}
defaultMessage={'Get help with <link>configuring outgoing OAuth connections</link>.'}
values={{
link: (text: string) => (
<a href='https://mattermost.com/pl/outgoing-oauth-connections'>{text}</a>
),
}}
/>
</div>
<ConfirmModal
show={isValidationModalOpen}
message={intl.formatMessage({
id: 'add_outgoing_oauth_connection.save_without_validation_warning',
defaultMessage: 'This connection has not been validated, Do you want to save anyway?',
})}
title={intl.formatMessage({
id: 'add_outgoing_oauth_connection.confirm_save',
defaultMessage: 'Save Outgoing OAuth Connection',
})}
confirmButtonText={intl.formatMessage({
id: 'add_outgoing_oauth_connection.save_anyway',
defaultMessage: 'Save anyway',
})}
onExited={hideSkipValidateModal}
onCancel={hideSkipValidateModal}
onConfirm={handleSubmit}
/>
</div>
);
}
type ValidateButtonProps = {
status: ValidationStatus;
onClick: (e: FormEvent) => void;
setUnvalidated: (e: FormEvent) => void;
}
const ValidateButton = ({status, onClick, setUnvalidated}: ValidateButtonProps) => {
if (status === ValidationStatus.ERROR) {
return (
<span
className='outgoing-oauth-connection-validation-message validation-error'
>
<AlertOutlineIcon size={20}/>
<FormattedMessage
id={'add_outgoing_oauth_connection.validation_error'}
defaultMessage={'Connection not validated. Please check the server logs for details or <link>try again</link>.'}
values={{
link: (text: string) => <a onClick={setUnvalidated}>{text}</a>,
}}
/>
</span>
);
}
if (status === ValidationStatus.VALIDATED) {
return (
<span
className='outgoing-oauth-connection-validation-message validation-success'
>
<CheckCircleOutlineIcon size={20}/>
<FormattedMessage
id={'add_outgoing_oauth_connection.validated_connection'}
defaultMessage={'Validated connection'}
/>
</span>
);
}
if (status === ValidationStatus.VALIDATING) {
return (
<span
className='outgoing-oauth-connection-validation-message'
>
<LoadingSpinner
text={(
<FormattedMessage
id={'add_outgoing_oauth_connection.validating'}
defaultMessage={'Validating...'}
/>
)}
/>
</span>
);
}
const validateButton = (
<button
className='btn btn-tertiary btn-sm'
type='button'
onClick={onClick}
id='validateConnection'
>
<FormattedMessage
id={'add_outgoing_oauth_connection.validate'}
defaultMessage={'Validate Connection'}
/>
</button>
);
return validateButton;
};

View File

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import {Permissions} from 'mattermost-redux/constants';
import AddOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/add_outgoing_oauth_connection';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/AddOutgoingOAuthConnection', () => {
const team = TestHelper.getTeamMock({
id: 'dbcxd9wpzpbpfp8pad78xj12pr',
name: 'test',
});
test('should match snapshot', () => {
const baseProps: React.ComponentProps<typeof AddOutgoingOAuthConnection> = {
team,
};
const state = {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
},
};
const props = {...baseProps};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<AddOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {defineMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import {useHistory} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import {addOutgoingOAuthConnection} from 'mattermost-redux/actions/integrations';
import AbstractOutgoingOAuthConnection from './abstract_outgoing_oauth_connection';
const HEADER = defineMessage({id: 'add_outgoing_oauth_connection.add', defaultMessage: 'Add'});
const FOOTER = defineMessage({id: 'add_outgoing_oauth_connection.save', defaultMessage: 'Save'});
const LOADING = defineMessage({id: 'add_outgoing_oauth_connection.saving', defaultMessage: 'Saving...'});
export type Props = {
team: Team;
};
const AddOutgoingOAuthConnection = ({team}: Props): JSX.Element => {
const dispatch = useDispatch();
const history = useHistory();
const [serverError, setServerError] = useState('');
const submit = async (connection: OutgoingOAuthConnection) => {
setServerError('');
const {data, error} = (await dispatch(addOutgoingOAuthConnection(team.id, connection))) as unknown as {data: OutgoingOAuthConnection; error: Error};
if (data) {
history.push(`/${team.name}/integrations/confirm?type=outgoing-oauth2-connections&id=${data.id}`);
return;
}
if (error) {
setServerError(error.message);
}
};
return (
<AbstractOutgoingOAuthConnection
team={team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
submitAction={submit}
serverError={serverError}
/>
);
};
export default AddOutgoingOAuthConnection;

View File

@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {Permissions} from 'mattermost-redux/constants';
import EditOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/edit_outgoing_oauth_connection';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/EditOutgoingOAuthConnection', () => {
const team = TestHelper.getTeamMock({
id: 'dbcxd9wpzpbpfp8pad78xj12pr',
name: 'test',
});
const outgoingOAuthConnections: Record<string, OutgoingOAuthConnection> = {
facxd9wpzpbpfp8pad78xj75pr: {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'firstConnection',
client_secret: '',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
audiences: ['https://mysite.com/api', 'https://myothersite.com/api/v2'],
client_id: 'client1',
grant_type: 'client_credentials',
oauth_token_url: 'https://oauthtoken1.com/oauth/token',
},
fzcxd9wpzpbpfp8pad78xj75pr: {
id: 'fzcxd9wpzpbpfp8pad78xj75pr',
name: 'secondConnection',
client_secret: '',
create_at: 1501365458935,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458935,
audiences: ['https://myaudience.com/api'],
client_id: 'client2',
grant_type: 'client_credentials',
oauth_token_url: 'https://oauthtoken2.com/oauth/token',
},
};
const state = {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
integrations: {
outgoingOAuthConnections,
},
},
};
const connection: OutgoingOAuthConnection = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testApp',
client_secret: '88cxd9wpzpbpfp8pad78xj75pr',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
audiences: ['https://test.com/callback', 'https://test.com/callback2'],
client_id: 'someclientid',
grant_type: 'client_credentials',
oauth_token_url: 'https://mytoken.url',
};
const baseProps: React.ComponentProps<typeof EditOutgoingOAuthConnection> = {
team,
location: {
search: '?id=facxd9wpzpbpfp8pad78xj75pr',
} as any,
};
test('should match snapshot, loading', () => {
const props = {...baseProps, oauthApp: connection};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<EditOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot', () => {
const props = {...baseProps, oauthApp: connection};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<EditOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot when EnableOAuthServiceProvider is false', () => {
const props = {...baseProps, oauthApp: connection, enableOAuthServiceProvider: false};
const store = mockStore(state);
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<EditOutgoingOAuthConnection {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage, defineMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import {editOutgoingOAuthConnection, getOutgoingOAuthConnection} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations';
import ConfirmModal from 'components/confirm_modal';
import LoadingScreen from 'components/loading_screen';
import {getHistory} from 'utils/browser_history';
import AbstractOutgoingOAuthConnection from './abstract_outgoing_oauth_connection';
const HEADER = defineMessage({id: 'integrations.edit', defaultMessage: 'Edit'});
const FOOTER = defineMessage({id: 'edit_outgoing_oauth_connection.update', defaultMessage: 'Update'});
const LOADING = defineMessage({id: 'edit_outgoing_oauth_connection.updating', defaultMessage: 'Updating...'});
type Props = {
team: Team;
location: Location;
};
const getConnectionIdFromSearch = (search: string): string => {
return (new URLSearchParams(search)).get('id') || '';
};
const EditOutgoingOAuthConnection = (props: Props) => {
const connectionId = getConnectionIdFromSearch(props.location.search);
const connections = useSelector(getOutgoingOAuthConnections);
const existingConnection = connections[connectionId];
const [newConnection, setNewConnection] = useState(existingConnection);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [serverError, setServerError] = useState('');
const enableOAuthServiceProvider = useSelector(getConfig).EnableOAuthServiceProvider;
const dispatch = useDispatch();
useEffect(() => {
if (enableOAuthServiceProvider) {
dispatch(getOutgoingOAuthConnection(props.team.id, connectionId));
}
}, [connectionId, enableOAuthServiceProvider, props.team, dispatch]);
const handleInitialSubmit = async (connection: OutgoingOAuthConnection) => {
setNewConnection(connection);
if (existingConnection.id) {
connection.id = existingConnection.id;
}
const audienceUrlsSame = (existingConnection.audiences.length === connection.audiences.length) &&
existingConnection.audiences.every((v, i) => v === connection.audiences[i]);
if (audienceUrlsSame) {
await createOutgoingOAuthConnection(connection);
} else {
handleConfirmModal();
}
};
const handleConfirmModal = () => {
setShowConfirmModal(true);
};
const confirmModalDismissed = () => {
setShowConfirmModal(false);
};
const createOutgoingOAuthConnection = async (connection: OutgoingOAuthConnection) => {
setServerError('');
const res = await dispatch(editOutgoingOAuthConnection(props.team.id, connection));
if ('data' in res && res.data) {
getHistory().push(`/${props.team.name}/integrations/outgoing-oauth2-connections`);
return;
}
confirmModalDismissed();
if ('error' in res) {
const {error: err} = res as {error: Error};
setServerError(err.message);
}
};
const renderExtra = () => {
const confirmButton = (
<FormattedMessage
id='update_command.update'
defaultMessage='Update'
/>
);
const confirmTitle = (
<FormattedMessage
id='update_outgoing_oauth_connection.confirm'
defaultMessage='Edit Outgoing OAuth Connection'
/>
);
const confirmMessage = (
<FormattedMessage
id='update_outgoing_oauth_connection.question'
defaultMessage='Your changes may break any existing integrations using this connection. Are you sure you would like to update it?'
/>
);
return (
<ConfirmModal
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
modalClass='integrations-backstage-modal'
show={showConfirmModal}
onConfirm={() => createOutgoingOAuthConnection(newConnection)}
onCancel={confirmModalDismissed}
/>
);
};
if (!existingConnection) {
return <LoadingScreen/>;
}
return (
<AbstractOutgoingOAuthConnection
team={props.team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
renderExtra={renderExtra()}
submitAction={handleInitialSubmit}
serverError={serverError}
initialConnection={existingConnection}
/>
);
};
export default EditOutgoingOAuthConnection;

View File

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import DeleteIntegrationLink from 'components/integrations/delete_integration_link';
import type {InstalledOutgoingOAuthConnectionProps} from 'components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connection';
import InstalledOutgoingOAuthConnection from 'components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connection';
describe('components/integrations/InstalledOutgoingOAuthConnection', () => {
const team = {name: 'team_name'};
const outgoingOAuthConnection: OutgoingOAuthConnection = {
id: 'someid',
create_at: 1501365458934,
creator_id: 'someuserid',
update_at: 1501365458934,
audiences: ['https://myaudience.com'],
client_id: 'someid',
client_secret: '',
grant_type: 'client_credentials',
name: 'My OAuth Connection',
oauth_token_url: 'https://tokenurl.com',
};
const baseProps: InstalledOutgoingOAuthConnectionProps = {
team,
outgoingOAuthConnection,
creatorName: 'somename',
onDelete: jest.fn(),
filter: '',
};
test('should match snapshot', () => {
const props = {...baseProps};
const wrapper = shallow(
<InstalledOutgoingOAuthConnection {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should have called props.onDelete on handleDelete ', () => {
const newOnDelete = jest.fn();
const props = {...baseProps, team, onDelete: newOnDelete};
const wrapper = shallow(
<InstalledOutgoingOAuthConnection {...props}/>,
);
expect(wrapper.find(DeleteIntegrationLink).exists()).toBe(true);
wrapper.find(DeleteIntegrationLink).props().onDelete();
expect(newOnDelete).toBeCalled();
expect(newOnDelete).toHaveBeenCalledWith(outgoingOAuthConnection);
});
});

View File

@ -0,0 +1,197 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import DeleteIntegrationLink from '../delete_integration_link';
export function matchesFilter(outgoingOAuthConnection: OutgoingOAuthConnection, filter?: string | null): boolean {
if (!filter) {
return true;
}
return outgoingOAuthConnection.name.toLowerCase().includes(filter);
}
export type InstalledOutgoingOAuthConnectionProps = {
team: Partial<Team>;
outgoingOAuthConnection: OutgoingOAuthConnection;
creatorName: string;
filter?: string | null;
onDelete: (outgoingOAuthConnection: OutgoingOAuthConnection) => void;
}
export type InstalledOutgoingOAuthConnectionState = {
clientSecret: string;
error?: string | null;
}
const InstalledOutgoingOAuthConnection = (props: InstalledOutgoingOAuthConnectionProps) => {
const handleDelete = (): void => {
props.onDelete(props.outgoingOAuthConnection);
};
const {outgoingOAuthConnection, creatorName} = props;
if (!matchesFilter(outgoingOAuthConnection, props.filter)) {
return null;
}
let name;
if (outgoingOAuthConnection.name) {
name = outgoingOAuthConnection.name;
} else {
name = (
<FormattedMessage
id='installed_integrations.unnamed_outgoing_oauth_connection'
defaultMessage='Unnamed Outgoing OAuth Connection'
/>
);
}
const urls = (
<>
<div className='item-details__row'>
<span className='item-details__url word-break--all'>
<FormattedMessage
id='installed_integrations.audience_urls'
defaultMessage='Audience URLs: {urls}'
values={{
urls: outgoingOAuthConnection.audiences.join(', '),
}}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__url word-break--all'>
<FormattedMessage
id='installed_integrations.token_url'
defaultMessage='Token URL: {url}'
values={{
url: outgoingOAuthConnection.oauth_token_url,
}}
/>
</span>
</div>
</>
);
const actions = (
<div className='item-actions'>
<Link to={`/${props.team.name}/integrations/outgoing-oauth2-connections/edit?id=${outgoingOAuthConnection.id}`}>
<FormattedMessage
id='installed_integrations.edit'
defaultMessage='Edit'
/>
</Link>
{' - '}
<DeleteIntegrationLink
subtitleText={
<FormattedMessage
id='installed_outgoing_oauth_connections.delete.confirm'
defaultMessage='Are you sure you want to delete {connectionName}?'
values={{
connectionName: (
<strong>
{props.outgoingOAuthConnection.name}
</strong>
),
}}
/>
}
modalMessage={
<FormattedMessage
id='installed_outgoing_oauth_connections.delete.wanring'
defaultMessage='Deleting this connection will break any integrations using it'
/>
}
onDelete={handleDelete}
/>
</div>
);
const connectionInfo = (
<>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMarkdownMessage
id='installed_integrations.client_id'
defaultMessage='Client ID: **{clientId}**'
values={{
clientId: outgoingOAuthConnection.client_id,
}}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMessage
id='installed_outgoing_oauth_connections.client_secret'
defaultMessage='Client Secret: ********'
/>
</span>
</div>
{outgoingOAuthConnection.grant_type === 'password' && (
<>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMarkdownMessage
id='installed_outgoing_oauth_connections.username'
defaultMessage='Username: **{username}**'
values={{
username: outgoingOAuthConnection.credentials_username,
}}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMessage
id='installed_outgoing_oauth_connections.password'
defaultMessage='Password: ********'
/>
</span>
</div>
</>
)}
{urls}
<div className='item-details__row'>
<span className='item-details__creation'>
<FormattedMessage
id='installed_integrations.creation'
defaultMessage='Created by {creator} on {createAt, date, full}'
values={{
creator: creatorName,
createAt: outgoingOAuthConnection.create_at,
}}
/>
</span>
</div>
</>
);
return (
<div className='backstage-list__item' >
<div className='item-details' >
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<strong className='item-details__name'>
{name}
</strong>
{actions}
</div>
{connectionInfo}
</div>
</div >
);
};
export default InstalledOutgoingOAuthConnection;

View File

@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {act} from 'react-dom/test-utils';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {Permissions} from 'mattermost-redux/constants';
import InstalledOutgoingOAuthConnections from 'components/integrations/outgoing_oauth_connections/installed_outgoing_oauth_connections';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/InstalledOutgoingOAuthConnections', () => {
const outgoingOAuthConnections: Record<string, OutgoingOAuthConnection> = {
facxd9wpzpbpfp8pad78xj75pr: {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'firstConnection',
client_secret: '',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
audiences: ['https://mysite.com/api', 'https://myothersite.com/api/v2'],
client_id: 'client1',
grant_type: 'client_credentials',
oauth_token_url: 'https://oauthtoken1.com/oauth/token',
},
fzcxd9wpzpbpfp8pad78xj75pr: {
id: 'fzcxd9wpzpbpfp8pad78xj75pr',
name: 'secondConnection',
client_secret: '',
create_at: 1501365458935,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458935,
audiences: ['https://myaudience.com/api'],
client_id: 'client2',
grant_type: 'client_credentials',
oauth_token_url: 'https://oauthtoken2.com/oauth/token',
},
};
const team = TestHelper.getTeamMock({name: 'test'});
const baseProps: React.ComponentProps<typeof InstalledOutgoingOAuthConnections> = {
team,
};
test('should match snapshot', async () => {
const state = {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
integrations: {
outgoingOAuthConnections,
},
},
};
const props = {...baseProps};
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<InstalledOutgoingOAuthConnections {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {Team} from '@mattermost/types/teams';
import {deleteOutgoingOAuthConnection} from 'mattermost-redux/actions/integrations';
import {Permissions} from 'mattermost-redux/constants';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations';
import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {loadOutgoingOAuthConnectionsAndProfiles} from 'actions/integration_actions';
import BackstageList from 'components/backstage/components/backstage_list';
import ExternalLink from 'components/external_link';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import {DeveloperLinks} from 'utils/constants';
import type {GlobalState} from 'types/store';
import InstalledOutgoingOAuthConnection, {matchesFilter} from './installed_outgoing_oauth_connection';
type Props = {
team: Team;
};
const InstalledOutgoingOAuthConnections = (props: Props) => {
const [loading, setLoading] = useState(true);
const canManageOutgoingOAuthConnections = useSelector((state) => haveITeamPermission(state as GlobalState, props.team.id, Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS));
const enableOutgoingOAuthConnections = (useSelector(getConfig).EnableOutgoingOAuthConnections === 'true');
const connections = useSelector(getOutgoingOAuthConnections);
const dispatch = useDispatch();
const intl = useIntl();
useEffect(() => {
if (canManageOutgoingOAuthConnections) {
(dispatch(loadOutgoingOAuthConnectionsAndProfiles(props.team.id)) as unknown as Promise<void>).then(
() => setLoading(false),
);
}
}, [canManageOutgoingOAuthConnections, props.team, dispatch]);
const deleteOutgoingOAuthConnectionLocal = (connection: OutgoingOAuthConnection): void => {
if (connection && connection.id) {
dispatch(deleteOutgoingOAuthConnection(connection.id));
}
};
const outgoingOauthConnectionCompare = (a: OutgoingOAuthConnection, b: OutgoingOAuthConnection): number => {
let nameA = a.name.toString();
if (!nameA) {
nameA = intl.formatMessage({id: 'installed_integrations.unnamed_outgoing_oauth_connection', defaultMessage: 'Unnamed Outgoing OAuth Connection'});
}
let nameB = b.name.toString();
if (!nameB) {
nameB = intl.formatMessage({id: 'installed_integrations.unnamed_outgoing_oauth_connection', defaultMessage: 'Unnamed Outgoing OAuth Connection'});
}
return nameA.localeCompare(nameB);
};
const outgoingOauthConnections = (filter?: string) => {
const values = Object.values(connections);
const filtered = values.filter((connection) => matchesFilter(connection, filter));
const sorted = filtered.sort(outgoingOauthConnectionCompare);
const mapped = sorted.map((connection) => {
return (
<InstalledOutgoingOAuthConnection
key={connection.id}
outgoingOAuthConnection={connection}
onDelete={deleteOutgoingOAuthConnectionLocal}
team={props.team}
creatorName=''
/>
);
});
return mapped;
};
const integrationsEnabled = enableOutgoingOAuthConnections && canManageOutgoingOAuthConnections;
let childProps;
if (integrationsEnabled) {
childProps = {
addLink: '/' + props.team.name + '/integrations/outgoing-oauth2-connections/add',
addText: intl.formatMessage({id: 'installed_outgoing_oauth_connections.add', defaultMessage: 'Add Outgoing OAuth Connection'}),
addButtonId: 'addOutgoingOauthConnection',
};
}
return (
<BackstageList
header={
<FormattedMessage
id='installed_outgoing_oauth_connections.header'
defaultMessage='Outgoing OAuth Connections'
/>
}
helpText={
<FormattedMessage
id='installed_outgoing_oauth_connections.help'
defaultMessage='Create {outgoingOauthConnections} to securely integrate bots and third-party apps with Mattermost.'
values={{
outgoingOauthConnections: (
<ExternalLink
href={DeveloperLinks.SETUP_OAUTH2}
location='installed_outgoing_oauth_connections'
>
<FormattedMessage
id='installed_outgoing_oauth_connections.help.outgoingOauthConnections'
defaultMessage='Outgoing OAuth Connections'
/>
</ExternalLink>
),
}}
/>
}
emptyText={
<FormattedMessage
id='installed_outgoing_oauth_connections.empty'
defaultMessage='No Outgoing OAuth Connections found'
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
id='installed_outgoing_oauth_connections.emptySearch'
defaultMessage='No Outgoing OAuth Connections match {searchTerm}'
/>
}
searchPlaceholder={intl.formatMessage({id: 'installed_outgoing_oauth_connections.search', defaultMessage: 'Search Outgoing OAuth Connections'})}
loading={loading}
{...childProps}
>
{(filter: string) => {
const children = outgoingOauthConnections(filter);
return [children, children.length > 0];
}}
</BackstageList>
);
};
export default InstalledOutgoingOAuthConnections;

View File

@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act} from '@testing-library/react';
import React from 'react';
import {Provider} from 'react-redux';
import {BrowserRouter as Router} from 'react-router-dom';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {Permissions} from 'mattermost-redux/constants';
import OAuthConnectionAudienceInput from 'components/integrations/outgoing_oauth_connections/oauth_connection_audience_input';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import mockStore from 'tests/test_store';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/outgoing_oauth_connections/OAuthConnectionAudienceInput', () => {
const connection: OutgoingOAuthConnection = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testConnection',
client_secret: '',
client_id: 'clientid',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
update_at: 1501365458934,
grant_type: 'client_credentials',
oauth_token_url: 'https://token.com',
audiences: ['https://aud.com/api'],
};
const baseProps: React.ComponentProps<typeof OAuthConnectionAudienceInput> = {
value: '',
onChange: jest.fn(),
placeholder: '',
};
const team = TestHelper.getTeamMock({name: 'test'});
const stateFromOAuthConnections = (connections: Record<string, OutgoingOAuthConnection>) => {
return {
entities: {
general: {
config: {
EnableOutgoingOAuthConnections: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
teams: {
teams: {
[team.id]: team,
},
currentTeamId: team.id,
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
roles: {
roles: {
system_role: {id: 'system_role', permissions: [Permissions.MANAGE_OUTGOING_OAUTH_CONNECTIONS]},
},
},
integrations: {
outgoingOAuthConnections: connections,
},
},
};
};
test('should match snapshot with no existing connections', async () => {
const props = {...baseProps};
const state = stateFromOAuthConnections({});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
test('should match snapshot with existing connections', async () => {
const props = {...baseProps};
const state = stateFromOAuthConnections({[connection.id]: connection});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
test('should match snapshot when typed in value matches a configured audience', async () => {
const props = {...baseProps, value: 'https://aud.com/api'};
const state = stateFromOAuthConnections({[connection.id]: connection});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
test('should match snapshot when typed in value does not have an exact match', async () => {
const props = {...baseProps, value: 'https://aud.com/api/no_match'};
const state = stateFromOAuthConnections({[connection.id]: connection});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
test('should match snapshot when an audience url with a wildcard is configured, and typed in value starts with configured audience url', async () => {
const props = {...baseProps, value: 'https://aud.com/api/it_matches'};
const state = stateFromOAuthConnections({[connection.id]: {...connection, audiences: ['https://aud.com/api/*']}});
const store = mockStore(state);
await act(async () => {
const wrapper = mountWithIntl(
<Router>
<Provider store={store}>
<OAuthConnectionAudienceInput {...props}/>
</Provider>
</Router>,
);
expect(wrapper).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {OauthIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components';
import type {OutgoingOAuthConnection} from '@mattermost/types/integrations';
import {
getOutgoingOAuthConnectionsForAudience as fetchOutgoingOAuthConnectionsForAudience,
getOutgoingOAuthConnections as fetchOutgoingOAuthConnections,
} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getOutgoingOAuthConnections} from 'mattermost-redux/selectors/entities/integrations';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
type Props = {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder: string;
}
const OAuthConnectionAudienceInput = (props: Props) => {
const mounted = useRef(false);
const [matchedConnection, setMatchingOAuthConnection] = useState<OutgoingOAuthConnection | null>(null);
const [loadingAudienceMatch, setLoadingAudienceMatch] = useState(false);
const oauthConnections = useSelector(getOutgoingOAuthConnections);
const oauthConnectionsEnabled = useSelector(getConfig).EnableOutgoingOAuthConnections === 'true';
const teamId = useSelector(getCurrentTeamId);
const dispatch = useDispatch();
const matchConnectionsOnInput = useCallback(async (inputValue: string) => {
const res = await dispatch(fetchOutgoingOAuthConnectionsForAudience(teamId, inputValue));
setLoadingAudienceMatch(false);
if (res.data && res.data.length) {
setMatchingOAuthConnection(res.data[0]);
} else {
setMatchingOAuthConnection(null);
}
}, [dispatch, teamId]);
const debouncedMatchConnections = useMemo(() => {
return debounce((inputValue: string) => matchConnectionsOnInput(inputValue), 1000);
}, [matchConnectionsOnInput]);
useEffect(() => {
if (mounted.current) {
return;
}
mounted.current = true;
if (oauthConnectionsEnabled) {
dispatch(fetchOutgoingOAuthConnections(teamId));
if (props.value) {
setLoadingAudienceMatch(true);
matchConnectionsOnInput(props.value);
}
}
}, [oauthConnectionsEnabled, props.value, teamId, matchConnectionsOnInput, dispatch, mounted]);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
props.onChange(e);
if (oauthConnectionsEnabled) {
setLoadingAudienceMatch(true);
debouncedMatchConnections(e.target.value);
}
};
const connections = Object.values(oauthConnections);
const input = (
<input
autoComplete='off'
id='url'
maxLength={1024}
className='form-control'
value={props.value}
onChange={onChange}
placeholder={props.placeholder}
/>
);
if (!connections.length) {
return input;
}
let oauthMessage: React.ReactNode;
if (loadingAudienceMatch) {
oauthMessage = (
<span>
<LoadingSpinner/>
</span>
);
} else if (matchedConnection) {
oauthMessage = (
<>
<span>
<OauthIcon
size={20}
/>
</span>
<span className='outgoing-oauth-audience-match-message'>
<FormattedMessage
id='add_outgoing_oauth_connection.connected'
defaultMessage='Connected to "{connectionName}"'
values={{
connectionName: matchedConnection.name,
}}
/>
</span>
</>
);
} else {
oauthMessage = (
<>
<span>
<InformationOutlineIcon
size={20}
/>
</span>
<span className='outgoing-oauth-audience-match-message'>
<FormattedMessage
id='add_outgoing_oauth_connection.not_connected'
defaultMessage='Not linked to an OAuth connection'
/>
</span>
</>
);
}
return (
<>
{input}
<div className='outgoing-oauth-audience-match-message-container'>
{oauthMessage}
</div>
</>
);
};
export default OAuthConnectionAudienceInput;

View File

@ -88,6 +88,7 @@
"add_command.method.get": "GET",
"add_command.method.help": "Specify the type of request, either POST or GET, sent to the endpoint that Mattermost hits to reach your application.",
"add_command.method.post": "POST",
"add_command.outgoing_oauth_connections.help_text": "You can connect commands to <link>outgoing OAuth connections</link>.",
"add_command.save": "Save",
"add_command.saving": "Saving...",
"add_command.token": "**Token**: {token}",
@ -162,6 +163,44 @@
"add_oauth_app.nameRequired": "Name for the OAuth 2.0 application is required.",
"add_oauth_app.trusted.help": "If true, the OAuth 2.0 application is considered trusted by the Mattermost server and does not require the user to accept authorization. If false, a window opens to ask the user to accept or deny the authorization.",
"add_oauth_app.url": "**URL(s)**: {url}",
"add_outgoing_oauth_connection.add": "Add",
"add_outgoing_oauth_connection.audience_urls": "**Audience URL(s)**: `{url}`",
"add_outgoing_oauth_connection.audienceUrls.help": "The URLs which will receive requests with the OAuth token, e.g. your custom slash command handler endpoint. Must be a valid URL and start with http:// or https://.",
"add_outgoing_oauth_connection.audienceUrls.label": "Audience URLs (One Per Line)",
"add_outgoing_oauth_connection.audienceUrls.required": "One or more audience URLs are required.",
"add_outgoing_oauth_connection.cancel": "Cancel",
"add_outgoing_oauth_connection.client_id.help": "Specify the Client ID for your OAuth connection.",
"add_outgoing_oauth_connection.client_id.label": "Client ID",
"add_outgoing_oauth_connection.client_id.required": "Client Id for the OAuth connection is required.",
"add_outgoing_oauth_connection.client_secret.help": "Specify the Client Secret for your OAuth connection.",
"add_outgoing_oauth_connection.client_secret.label": "Client Secret",
"add_outgoing_oauth_connection.client_secret.required": "Client Secret for the OAuth connection is required.",
"add_outgoing_oauth_connection.clientId": "**Client ID**: {id}",
"add_outgoing_oauth_connection.clientSecret": "**Client Secret**: \\*\\*\\*\\*\\*\\*\\*\\*",
"add_outgoing_oauth_connection.confirm_save": "Save Outgoing OAuth Connection",
"add_outgoing_oauth_connection.connected": "Connected to \"{connectionName}\"",
"add_outgoing_oauth_connection.documentation_link": "Get help with <link>configuring outgoing OAuth connections</link>.",
"add_outgoing_oauth_connection.doneHelp": "Your Outgoing OAuth 2.0 Connection is set up. When a request is sent to one of the following Audience URLs, the Client ID and Client Secret will now be used to retrieve a token from the Token URL, before sending the integration request (details at <link>Outgoing OAuth 2.0 Connections</link>).",
"add_outgoing_oauth_connection.grant_type.required": "Grant Type for the OAuth connection is required.",
"add_outgoing_oauth_connection.header": "Outgoing OAuth Connections",
"add_outgoing_oauth_connection.name.help": "Specify the name for your OAuth connection.",
"add_outgoing_oauth_connection.name.label": "Name",
"add_outgoing_oauth_connection.name.required": "Name for the OAuth connection is required.",
"add_outgoing_oauth_connection.not_connected": "Not linked to an OAuth connection",
"add_outgoing_oauth_connection.oauth_token_url.help": "Specify the OAuth Token URL for your OAuth connection.",
"add_outgoing_oauth_connection.oauth_token_url.label": "OAuth Token URL",
"add_outgoing_oauth_connection.oauth_token_url.required": "OAuth Token URL for the OAuth connection is required.",
"add_outgoing_oauth_connection.password": "**Password**: {password}",
"add_outgoing_oauth_connection.save": "Save",
"add_outgoing_oauth_connection.save_anyway": "Save anyway",
"add_outgoing_oauth_connection.save_without_validation_warning": "This connection has not been validated, Do you want to save anyway?",
"add_outgoing_oauth_connection.saving": "Saving...",
"add_outgoing_oauth_connection.token_url": "**Token URL**: `{url}`",
"add_outgoing_oauth_connection.username": "**Username**: {username}",
"add_outgoing_oauth_connection.validate": "Validate Connection",
"add_outgoing_oauth_connection.validated_connection": "Validated connection",
"add_outgoing_oauth_connection.validating": "Validating...",
"add_outgoing_oauth_connection.validation_error": "Connection not validated. Please check the server logs for details or <link>try again</link>.",
"add_outgoing_webhook.callbackUrls": "Callback URLs (One Per Line)",
"add_outgoing_webhook.callbackUrls.help": "Specify the URL that messages will be sent to. If the URL is private, add it as a {link}.",
"add_outgoing_webhook.callbackUrls.helpLinkText": "trusted internal connection",
@ -1677,6 +1716,8 @@
"admin.permissions.permission.manage_jobs.name": "Manage jobs",
"admin.permissions.permission.manage_oauth.description": "Create, edit and delete OAuth 2.0 application tokens.",
"admin.permissions.permission.manage_oauth.name": "Manage OAuth Applications",
"admin.permissions.permission.manage_outgoing_oauth_connections.description": "Create, edit, and delete outgoing OAuth credentials.",
"admin.permissions.permission.manage_outgoing_oauth_connections.name": "Manage Outgoing OAuth Credentials",
"admin.permissions.permission.manage_outgoing_webhooks.description": "Create, edit, and delete outgoing webhooks.",
"admin.permissions.permission.manage_outgoing_webhooks.name": "Manage Outgoing Webhooks",
"admin.permissions.permission.manage_private_channel_properties.description": "Update private channel names, headers and purposes.",
@ -2249,6 +2290,8 @@
"admin.service.mobileSessionHours": "Session Length Mobile (hours):",
"admin.service.mobileSessionHoursDesc": "The number of hours from the last time a user entered their credentials to the expiry of the user's session. After changing this setting, the new session length will take effect after the next time the user enters their credentials.",
"admin.service.mobileSessionHoursDesc.extendLength": "Set the number of hours from the last activity in Mattermost to the expiry of the users session on mobile. After changing this setting, the new session length will take effect after the next time the user enters their credentials.",
"admin.service.outgoingOAuthConnectionsDesc": "When true, outgoing webhooks and slash commands will use set up oauth connections to authenticate with third party services. See <link>documentation</link> to learn more.",
"admin.service.outgoingOAuthConnectionsTitle": "Enable Outgoing OAuth Connections: ",
"admin.service.outWebhooksDesc": "When true, outgoing webhooks will be allowed. See <link>documentation</link> to learn more.",
"admin.service.outWebhooksTitle": "Enable Outgoing Webhooks: ",
"admin.service.overrideDescription": "When true, webhooks, slash commands and other integrations will be allowed to change the username they are posting as. Note: Combined with allowing integrations to override profile picture icons, users may be able to perform phishing attacks by attempting to impersonate other users.",
@ -2952,6 +2995,7 @@
"backstage_sidebar.integrations.incoming_webhooks": "Incoming Webhooks",
"backstage_sidebar.integrations.oauthApps": "OAuth 2.0 Applications",
"backstage_sidebar.integrations.outgoing_webhooks": "Outgoing Webhooks",
"backstage_sidebar.integrations.outgoingOauthConnections": "Outgoing OAuth 2.0 Connections",
"billing_subscriptions.cloud_annual_renewal_alert_banner_title": "Your annual subscription expires in {days} days. Please renew now to avoid any disruption",
"billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired": "Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption",
"billing.subscription.info.mostRecentPaymentFailed": "Your most recent payment failed",
@ -3481,6 +3525,8 @@
"edit_channel_purpose_modal.title2": "Edit Purpose for ",
"edit_command.update": "Update",
"edit_command.updating": "Updating...",
"edit_outgoing_oauth_connection.update": "Update",
"edit_outgoing_oauth_connection.updating": "Updating...",
"edit_post.action_buttons.cancel": "Cancel",
"edit_post.action_buttons.save": "Save",
"edit_post.editPost": "Edit the post...",
@ -3831,6 +3877,7 @@
"installed_incoming_webhooks.help.buildYourOwn": "Build Your Own",
"installed_incoming_webhooks.search": "Search Incoming Webhooks",
"installed_incoming_webhooks.unknown_channel": "A Private Webhook",
"installed_integrations.audience_urls": "Audience URLs: {urls}",
"installed_integrations.callback_urls": "Callback URLs: {urls}",
"installed_integrations.client_id": "Client ID: **{clientId}**",
"installed_integrations.client_secret": "Client Secret: **{clientSecret}**",
@ -3844,9 +3891,11 @@
"installed_integrations.regenToken": "Regenerate Token",
"installed_integrations.showSecret": "Show Secret",
"installed_integrations.token": "Token: {token}",
"installed_integrations.token_url": "Token URL: {url}",
"installed_integrations.triggerWhen": "Trigger When: {triggerWhen}",
"installed_integrations.triggerWords": "Trigger Words: {triggerWords}",
"installed_integrations.unnamed_oauth_app": "Unnamed OAuth 2.0 Application",
"installed_integrations.unnamed_outgoing_oauth_connection": "Unnamed Outgoing OAuth Connection",
"installed_integrations.url": "URL: {url}",
"installed_oauth_apps.add": "Add OAuth 2.0 Application",
"installed_oauth_apps.callbackUrls": "Callback URLs (One Per Line)",
@ -3870,6 +3919,18 @@
"installed_oauth_apps.trusted.no": "No",
"installed_oauth_apps.trusted.yes": "Yes",
"installed_oauth2_apps.header": "OAuth 2.0 Applications",
"installed_outgoing_oauth_connections.add": "Add Outgoing OAuth Connection",
"installed_outgoing_oauth_connections.client_secret": "Client Secret: ********",
"installed_outgoing_oauth_connections.delete.confirm": "Are you sure you want to delete {connectionName}?",
"installed_outgoing_oauth_connections.delete.wanring": "Deleting this connection will break any integrations using it",
"installed_outgoing_oauth_connections.empty": "No Outgoing OAuth Connections found",
"installed_outgoing_oauth_connections.emptySearch": "No Outgoing OAuth Connections match {searchTerm}",
"installed_outgoing_oauth_connections.header": "Outgoing OAuth Connections",
"installed_outgoing_oauth_connections.help": "Create {outgoingOauthConnections} to securely integrate bots and third-party apps with Mattermost.",
"installed_outgoing_oauth_connections.help.outgoingOauthConnections": "Outgoing OAuth Connections",
"installed_outgoing_oauth_connections.password": "Password: ********",
"installed_outgoing_oauth_connections.search": "Search Outgoing OAuth Connections",
"installed_outgoing_oauth_connections.username": "Username: **{username}**",
"installed_outgoing_webhooks.add": "Add Outgoing Webhook",
"installed_outgoing_webhooks.delete.confirm": "This action permanently deletes the outgoing webhook and breaks any integrations using it. Are you sure you want to delete it?",
"installed_outgoing_webhooks.empty": "No outgoing webhooks found",
@ -3883,7 +3944,7 @@
"integrations.add": "Add",
"integrations.command.description": "Slash commands send events to external integrations",
"integrations.command.title": "Slash Commands",
"integrations.delete.confirm.button": "Delete",
"integrations.delete.confirm.button": "Yes, delete it",
"integrations.delete.confirm.title": "Delete Integration",
"integrations.done": "Done",
"integrations.edit": "Edit",
@ -3894,6 +3955,8 @@
"integrations.incomingWebhook.title": "Incoming Webhooks",
"integrations.oauthApps.description": "OAuth 2.0 allows external applications to make authorized requests to the Mattermost API",
"integrations.oauthApps.title": "OAuth 2.0 Applications",
"integrations.outgoingOAuthConnections.description": "Outgoing OAuth Connections allow custom integrations to communicate to external systems",
"integrations.outgoingOAuthConnections.title": "Outgoing OAuth Connections",
"integrations.outgoingWebhook.description": "Outgoing webhooks allow external integrations to receive and respond to messages",
"integrations.outgoingWebhook.title": "Outgoing Webhooks",
"integrations.successful": "Setup Successful",
@ -5307,6 +5370,8 @@
"update_incoming_webhook.updating": "Updating...",
"update_oauth_app.confirm": "Edit OAuth 2.0 application",
"update_oauth_app.question": "Your changes may break the existing OAuth 2.0 application. Are you sure you would like to update it?",
"update_outgoing_oauth_connection.confirm": "Edit Outgoing OAuth Connection",
"update_outgoing_oauth_connection.question": "Your changes may break any existing integrations using this connection. Are you sure you would like to update it?",
"update_outgoing_webhook.confirm": "Edit Outgoing Webhook",
"update_outgoing_webhook.question": "Your changes may break the existing outgoing webhook. Are you sure you would like to update it?",
"update_outgoing_webhook.update": "Update",

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -21,6 +21,9 @@ export default keyMirror({
DELETED_OAUTH_APP: null,
RECEIVED_APPS_OAUTH_APP_IDS: null,
RECEIVED_APPS_BOT_IDS: null,
RECEIVED_OUTGOING_OAUTH_CONNECTIONS: null,
RECEIVED_OUTGOING_OAUTH_CONNECTION: null,
DELETED_OUTGOING_OAUTH_CONNECTION: null,
RECEIVED_DIALOG_TRIGGER_ID: null,
RECEIVED_DIALOG: null,

View File

@ -3,7 +3,7 @@
import {batchActions} from 'redux-batched-actions';
import type {Command, CommandArgs, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingWebhook, SubmitDialogResponse} from '@mattermost/types/integrations';
import type {Command, CommandArgs, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingOAuthConnection, OutgoingWebhook, SubmitDialogResponse} from '@mattermost/types/integrations';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
@ -297,6 +297,74 @@ export function getOAuthApps(page = 0, perPage: number = General.PAGE_SIZE_DEFAU
});
}
export function getOutgoingOAuthConnections(teamId: string, page = 0, perPage: number = General.PAGE_SIZE_DEFAULT) {
return bindClientFunc({
clientFunc: Client4.getOutgoingOAuthConnections,
onSuccess: [IntegrationTypes.RECEIVED_OUTGOING_OAUTH_CONNECTIONS],
params: [
teamId,
page,
perPage,
],
});
}
export function getOutgoingOAuthConnectionsForAudience(teamId: string, audience: string, page = 0, perPage: number = General.PAGE_SIZE_DEFAULT) {
return bindClientFunc({
clientFunc: Client4.getOutgoingOAuthConnectionsForAudience,
onSuccess: [IntegrationTypes.RECEIVED_OUTGOING_OAUTH_CONNECTIONS],
params: [
teamId,
audience,
page,
perPage,
],
});
}
export function addOutgoingOAuthConnection(teamId: string, connection: OutgoingOAuthConnection) {
return bindClientFunc({
clientFunc: Client4.createOutgoingOAuthConnection,
onSuccess: [IntegrationTypes.RECEIVED_OUTGOING_OAUTH_CONNECTION],
params: [
teamId,
connection,
],
});
}
export function editOutgoingOAuthConnection(teamId: string, connection: OutgoingOAuthConnection) {
return bindClientFunc({
clientFunc: Client4.editOutgoingOAuthConnection,
onSuccess: IntegrationTypes.RECEIVED_OUTGOING_OAUTH_CONNECTION,
params: [
teamId,
connection,
],
});
}
export function getOutgoingOAuthConnection(teamId: string, connectionId: string) {
return bindClientFunc({
clientFunc: Client4.getOutgoingOAuthConnection,
onSuccess: [IntegrationTypes.RECEIVED_OUTGOING_OAUTH_CONNECTION],
params: [
teamId,
connectionId,
],
});
}
export function validateOutgoingOAuthConnection(teamId: string, connection: OutgoingOAuthConnection) {
return bindClientFunc({
clientFunc: Client4.validateOutgoingOAuthConnection,
params: [
teamId,
connection,
],
});
}
export function getAppsOAuthAppIDs() {
return bindClientFunc({
clientFunc: Client4.getAppsOAuthAppIDs,
@ -380,6 +448,26 @@ export function regenOAuthAppSecret(appId: string) {
});
}
export function deleteOutgoingOAuthConnection(id: string): ActionFuncAsync<boolean> {
return async (dispatch, getState) => {
try {
await Client4.deleteOutgoingOAuthConnection(id);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
dispatch({
type: IntegrationTypes.DELETED_OUTGOING_OAUTH_CONNECTION,
data: {id},
});
return {data: true};
};
}
export function submitInteractiveDialog(submission: DialogSubmission): ActionFuncAsync<SubmitDialogResponse> {
return async (dispatch, getState) => {
const state = getState();

View File

@ -48,6 +48,7 @@ const values = {
MANAGE_OUTGOING_WEBHOOKS: 'manage_outgoing_webhooks',
MANAGE_OTHERS_OUTGOING_WEBHOOKS: 'manage_others_outgoing_webhooks',
MANAGE_OAUTH: 'manage_oauth',
MANAGE_OUTGOING_OAUTH_CONNECTIONS: 'manage_outgoing_oauth_connections',
MANAGE_SYSTEM_WIDE_OAUTH: 'manage_system_wide_oauth',
CREATE_POST: 'create_post',
CREATE_POST_PUBLIC: 'create_post_public',

View File

@ -4,7 +4,7 @@
import type {AnyAction} from 'redux';
import {combineReducers} from 'redux';
import type {Command, IncomingWebhook, OutgoingWebhook, OAuthApp} from '@mattermost/types/integrations';
import type {Command, IncomingWebhook, OutgoingWebhook, OAuthApp, OutgoingOAuthConnection} from '@mattermost/types/integrations';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import {IntegrationTypes, UserTypes, ChannelTypes} from 'mattermost-redux/action_types';
@ -224,6 +224,33 @@ function appsOAuthAppIDs(state: string[] = [], action: AnyAction) {
}
}
function outgoingOAuthConnections(state: IDMappedObjects<OutgoingOAuthConnection> = {}, action: AnyAction) {
switch (action.type) {
case IntegrationTypes.RECEIVED_OUTGOING_OAUTH_CONNECTIONS: {
const nextState = {...state};
for (const connection of action.data) {
nextState[connection.id] = connection;
}
return nextState;
}
case IntegrationTypes.RECEIVED_OUTGOING_OAUTH_CONNECTION:
return {
...state,
[action.data.id]: action.data,
};
case IntegrationTypes.DELETED_OUTGOING_OAUTH_CONNECTION: {
const nextState = {...state};
Reflect.deleteProperty(nextState, action.data.id);
return nextState;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function appsBotIDs(state: string[] = [], action: AnyAction) {
switch (action.type) {
case IntegrationTypes.RECEIVED_APPS_BOT_IDS: {
@ -294,6 +321,9 @@ export default combineReducers({
// object to represent the list of ids for bots associated to apps
appsBotIDs,
// object to represent registered outgoing oauth connections with connection id as the key
outgoingOAuthConnections,
// object to represent built-in slash commands
systemCommands,

View File

@ -26,6 +26,10 @@ export function getOAuthApps(state: GlobalState) {
return state.entities.integrations.oauthApps;
}
export function getOutgoingOAuthConnections(state: GlobalState) {
return state.entities.integrations.outgoingOAuthConnections;
}
export const getAppsOAuthAppIDs: (state: GlobalState) => string[] = createSelector(
'getAppsOAuthAppIDs',
appsEnabled,

View File

@ -126,6 +126,7 @@ const state: GlobalState = {
commands: {},
appsBotIDs: [],
appsOAuthAppIDs: [],
outgoingOAuthConnections: {},
},
files: {
files: {},

View File

@ -192,7 +192,8 @@
.form-control[disabled],
.form-control[readonly],
fieldset[disabled] .form-control {
fieldset[disabled] .form-control,
.backstage-form .form-control[disabled] {
background-color: #f9f9f9;
}

View File

@ -41,7 +41,7 @@
.backstage-sidebar {
position: absolute;
width: 260px;
width: 270px;
padding: 46px 20px;
vertical-align: top;
@ -110,9 +110,9 @@
.section-title,
.subsection-title {
display: block;
padding-left: 2em;
padding: 6px 12px 6px 34px;
font-size: 0.95em;
line-height: 29px;
line-height: 20px;
text-decoration: none;
}
@ -226,6 +226,10 @@
text-overflow: ellipsis;
}
.item-actions {
margin-left: 6px;
}
.item-details__trigger {
margin-left: 6px;
}
@ -544,3 +548,66 @@
.bot-token-has-error {
color: #a94442;
}
.outgoing-oauth-connections-edit-secret {
position: absolute;
top: 8px;
right: 16px;
cursor: pointer;
.icon-pencil-outline {
opacity: 56%;
}
}
.outgoing-oauth-connection-validate-button-container {
height: 40px;
margin-top: 10px;
}
.outgoing-oauth-connection-validation-message {
&.validation-success {
color: #06d6a0;
}
&.validation-error {
color: #d24b4e;
}
svg {
margin-right: 5px;
// margin-top: 2px;
}
display: flex;
font-size: 13px;
font-weight: 700;
}
.outgoing-oauth-connections-docs-link {
margin-top: 40px;
}
.outgoing-oauth-audience-match-message-container {
height: 27px;
> span {
position: absolute;
top: 45px;
}
.outgoing-oauth-audience-match-message {
left: 42px;
}
}
#confirmModal.integrations-backstage-modal {
#confirmModalLabel,
#confirmModalBody {
color: rgb(51, 51, 51);
}
#confirmModalBody p {
height: 30px;
}
}

View File

@ -1908,6 +1908,7 @@ export const Constants = {
INCOMING_WEBHOOK: 'incoming_webhooks',
OUTGOING_WEBHOOK: 'outgoing_webhooks',
OAUTH_APP: 'oauth2-apps',
OUTGOING_OAUTH_CONNECTIONS: 'outgoing-oauth2-connections',
BOT: 'bots',
EXECUTE_CURRENT_COMMAND_ITEM_ID: '_execute_current_command',
OPEN_COMMAND_IN_MODAL_ITEM_ID: '_open_command_in_modal',

View File

@ -92,6 +92,7 @@ import {
DialogSubmission,
IncomingWebhook,
OAuthApp,
OutgoingOAuthConnection,
OutgoingWebhook,
SubmitDialogResponse,
} from '@mattermost/types/integrations';
@ -178,7 +179,7 @@ export default class Client4 {
csrf = '';
url = '';
urlVersion = '/api/v4';
userAgent: string|null = null;
userAgent: string | null = null;
enableLogging = false;
defaultHeaders: {[x: string]: string} = {};
userId = '';
@ -387,6 +388,15 @@ export default class Client4 {
return `${this.getOAuthAppsRoute()}/${appId}`;
}
getOutgoingOAuthConnectionsRoute() {
return `${this.getBaseRoute()}/oauth/outgoing_connections`;
}
getOutgoingOAuthConnectionRoute(connectionId: string) {
return `${this.getBaseRoute()}/oauth/outgoing_connections/${connectionId}`;
}
getEmojisRoute() {
return `${this.getBaseRoute()}/emoji`;
}
@ -2783,6 +2793,50 @@ export default class Client4 {
);
};
getOutgoingOAuthConnections = (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch<OutgoingOAuthConnection[]>(
`${this.getOutgoingOAuthConnectionsRoute()}${buildQueryString({team_id: teamId, page, per_page: perPage})}`,
{method: 'get'},
);
};
getOutgoingOAuthConnectionsForAudience = (teamId: string, audience: string, page = 0, perPage = PER_PAGE_DEFAULT) => {
return this.doFetch<OutgoingOAuthConnection[]>(
`${this.getOutgoingOAuthConnectionsRoute()}${buildQueryString({team_id: teamId, page, per_page: perPage, audience})}`,
{method: 'get'},
);
};
getOutgoingOAuthConnection = (teamId: string, connectionId: string) => {
return this.doFetch<OutgoingOAuthConnection>(
`${this.getOutgoingOAuthConnectionRoute(connectionId)}${buildQueryString({team_id: teamId})}`,
{method: 'get'},
);
};
createOutgoingOAuthConnection = (teamId: string, connection: OutgoingOAuthConnection) => {
this.trackEvent('api', 'api_outgoing_oauth_connection_register');
return this.doFetch<OutgoingOAuthConnection>(
`${this.getOutgoingOAuthConnectionsRoute()}${buildQueryString({team_id: teamId})}`,
{method: 'post', body: JSON.stringify(connection)},
);
};
editOutgoingOAuthConnection = (teamId: string, connection: OutgoingOAuthConnection) => {
return this.doFetch<OutgoingOAuthConnection>(
`${this.getOutgoingOAuthConnectionsRoute()}/${connection.id}${buildQueryString({team_id: teamId})}`,
{method: 'put', body: JSON.stringify(connection)},
);
};
validateOutgoingOAuthConnection = (teamId: string, connection: OutgoingOAuthConnection) => {
return this.doFetch<OutgoingOAuthConnection>(
`${this.getOutgoingOAuthConnectionsRoute()}/validate${buildQueryString({team_id: teamId})}`,
{method: 'post', body: JSON.stringify(connection)},
);
};
getOAuthAppInfo = (appId: string) => {
return this.doFetch<OAuthApp>(
`${this.getOAuthAppRoute(appId)}/info`,
@ -2806,6 +2860,15 @@ export default class Client4 {
);
};
deleteOutgoingOAuthConnection = (connectionId: string) => {
this.trackEvent('api', 'api_apps_delete');
return this.doFetch<StatusOK>(
`${this.getOutgoingOAuthConnectionRoute(connectionId)}`,
{method: 'delete'},
);
};
submitInteractiveDialog = (data: DialogSubmission) => {
this.trackEvent('api', 'api_interactive_messages_dialog_submitted');
return this.doFetch<SubmitDialogResponse>(

View File

@ -82,6 +82,7 @@ export type ClientConfig = {
EnableMobileFileUpload: string;
EnableMultifactorAuthentication: string;
EnableOAuthServiceProvider: string;
EnableOutgoingOAuthConnections: string;
EnableOpenServer: string;
EnableOutgoingWebhooks: string;
EnablePostIconOverride: string;

View File

@ -104,10 +104,26 @@ export type OAuthApp = {
'is_trusted': boolean;
};
export type OutgoingOAuthConnection = {
'id': string;
'name': string;
'creator_id': string;
'create_at': number;
'update_at': number;
'client_id': string;
'client_secret'?: string;
'credentials_username'?: string;
'credentials_password'?: string;
'oauth_token_url': string;
'grant_type': 'client_credentials' | 'password';
'audiences': string[];
};
export type IntegrationsState = {
incomingHooks: IDMappedObjects<IncomingWebhook>;
outgoingHooks: IDMappedObjects<OutgoingWebhook>;
oauthApps: IDMappedObjects<OAuthApp>;
outgoingOAuthConnections: IDMappedObjects<OutgoingOAuthConnection>;
appsOAuthAppIDs: string[];
appsBotIDs: string[];
systemCommands: IDMappedObjects<Command>;