mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
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 commit669da23e8e
. * Revert "updated i18n file" This reverts commitd0882c0dd7
. * Revert "fixed i18n" This reverts commit3108866bc1
. * 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:
parent
3f6c94cfc3
commit
4e071e861c
@ -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'
|
||||
|
@ -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
@ -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()
|
||||
|
@ -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
@ -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 {
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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{
|
||||
|
@ -342,6 +342,7 @@ func init() {
|
||||
PermissionSysconsoleWriteIntegrationsCors.Id,
|
||||
PermissionSysconsoleReadProductsBoards.Id,
|
||||
PermissionSysconsoleWriteProductsBoards.Id,
|
||||
PermissionManageOutgoingOAuthConnections.Id,
|
||||
}
|
||||
|
||||
SystemCustomGroupAdminDefaultPermissions = []string{
|
||||
|
@ -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};
|
||||
};
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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.',
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
@ -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`}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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'>
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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(
|
||||
|
@ -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') || '';
|
||||
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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: '',
|
||||
};
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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']}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
`;
|
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
`;
|
@ -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>
|
||||
`;
|
@ -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>
|
||||
`;
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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 user’s 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",
|
||||
|
BIN
webapp/channels/src/images/outgoing_oauth_connection.png
Normal file
BIN
webapp/channels/src/images/outgoing_oauth_connection.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
||||
|
@ -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,
|
||||
|
@ -126,6 +126,7 @@ const state: GlobalState = {
|
||||
commands: {},
|
||||
appsBotIDs: [],
|
||||
appsOAuthAppIDs: [],
|
||||
outgoingOAuthConnections: {},
|
||||
},
|
||||
files: {
|
||||
files: {},
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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>(
|
||||
|
@ -82,6 +82,7 @@ export type ClientConfig = {
|
||||
EnableMobileFileUpload: string;
|
||||
EnableMultifactorAuthentication: string;
|
||||
EnableOAuthServiceProvider: string;
|
||||
EnableOutgoingOAuthConnections: string;
|
||||
EnableOpenServer: string;
|
||||
EnableOutgoingWebhooks: string;
|
||||
EnablePostIconOverride: string;
|
||||
|
@ -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>;
|
||||
|
Loading…
Reference in New Issue
Block a user