Webapp - Outgoing OAuth Connections (#25507)

* added store

* make generated

* add missing license headers

* fix receiver name

* i18n

* i18n sorting

* update migrations from master

* make migrations-extract

* update retrylayer tests

* replaced sql query with id pagination

* fixed flaky tests

* missing columns

* missing columns on save/update

* typo

* improved tests

* remove enum from mysql colum

* add password credentials to store

* license changes

* OAuthOutgoingConnectionInterface

* Oauth -> OAuth

* make generated

* copied over installed_oauth_apps component and renamed things to installed_outgoing_oauth_connections

* merge migrations

* renamed migrations

* model change suggestions

* refactor test functionsn

* migration typo

* refactor store table names

* updated sanitize test

* cleanup merge

* refactor symbol

* "installed outgoing oauth connections" page works

* move things into a nested folder

* add and edit page stubs work

* list endpoint

* oauthoutgoingconnection -> outgoingoauthconnection

* signature change

* i18n update

* granttype typo

* naming

* api list

* uppercase typo

* i18n

* missing license header

* fixed path in comments

* updated openapi definitions

* changes to support selecting command request url

* sanitize connections

* make generated

* test license and no feature flag

* removed t.fatal

* updated testhelper calls

* yaml schema fixes

* switched interface name

* suggested translation

* missing i18n translation

* management permission

* moved permission initalization to proper place

* endpoints

* put tests

* error check typo

* fixed specific enttity urls

* tests

* read permission check

* updated openapi definitions

* i18n

* GetConnectionByAudience method

* notes

* replaced GetConnectionsByAudience with a filter

* added custom oauth token object

* updated interface and usage

* properly set enterprise interface

* move retrieval logic to impl

* webhook tests

* translations

* i18n: updates

* address comments

* endpoint and tests

* i18n

* api docs

* fixed endpoint path

* sq.like

* use filter object instead of parameters

* set url values if not empty

* typos

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

* correctly check token url

* restore flag to previous value

* added command oauth handler

* update enterprise imports

* migrate last component to function component

* Added enterprise import

* refactor permissions and add necessary webapp code

* Check correct flag in permission tree

* allow partial updates

* sort i18n webapp

* missing test modification

* fixed webapp i18n sorting

* allow validating stored connections

* added missing translation

* fix finished adding connection link and text on result page

* added missing permission to smoke tests

* missing role in smoke test

* updated translations

* updated translations

* support editing client secret on existing connection

* fix some i18n strings

* updated translations

* better error messages

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

* remove writeheader, test

* HasValidGrantType

* end early to avoid nil pointer errors

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

* wrap components related to oauth connections in config check

* fix tests

* i18n-extract

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

* remove debug code

* fixed i18n

* updated i18n file

* feature configuration backend

* typo

* add system console setting

* Revert "typo"

This reverts commit 669da23e8ee47525ccaa6f59cbbd20bf8a121191.

* Revert "updated i18n file"

This reverts commit d0882c0dd7587533f0d0f7a7b7b190684186158a.

* Revert "fixed i18n"

This reverts commit 3108866bc19139182dfd094921c56cdefc4695ea.

* 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>
Этот коммит содержится в:
Michael Kochell
2024-02-09 14:49:49 -05:00
коммит произвёл GitHub
родитель 3f6c94cfc3
Коммит 4e071e861c
91 изменённых файлов: 10116 добавлений и 201 удалений

Просмотреть файл

@@ -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']}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -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>
`;

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -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 users session on mobile. After changing this setting, the new session length will take effect after the next time the user enters their credentials.",
"admin.service.outgoingOAuthConnectionsDesc": "When true, outgoing webhooks and slash commands will use set up oauth connections to authenticate with third party services. See <link>documentation</link> to learn more.",
"admin.service.outgoingOAuthConnectionsTitle": "Enable Outgoing OAuth Connections: ",
"admin.service.outWebhooksDesc": "When true, outgoing webhooks will be allowed. See <link>documentation</link> to learn more.",
"admin.service.outWebhooksTitle": "Enable Outgoing Webhooks: ",
"admin.service.overrideDescription": "When true, webhooks, slash commands and other integrations will be allowed to change the username they are posting as. Note: Combined with allowing integrations to override profile picture icons, users may be able to perform phishing attacks by attempting to impersonate other users.",
@@ -2952,6 +2995,7 @@
"backstage_sidebar.integrations.incoming_webhooks": "Incoming Webhooks",
"backstage_sidebar.integrations.oauthApps": "OAuth 2.0 Applications",
"backstage_sidebar.integrations.outgoing_webhooks": "Outgoing Webhooks",
"backstage_sidebar.integrations.outgoingOauthConnections": "Outgoing OAuth 2.0 Connections",
"billing_subscriptions.cloud_annual_renewal_alert_banner_title": "Your annual subscription expires in {days} days. Please renew now to avoid any disruption",
"billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired": "Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption",
"billing.subscription.info.mostRecentPaymentFailed": "Your most recent payment failed",
@@ -3481,6 +3525,8 @@
"edit_channel_purpose_modal.title2": "Edit Purpose for ",
"edit_command.update": "Update",
"edit_command.updating": "Updating...",
"edit_outgoing_oauth_connection.update": "Update",
"edit_outgoing_oauth_connection.updating": "Updating...",
"edit_post.action_buttons.cancel": "Cancel",
"edit_post.action_buttons.save": "Save",
"edit_post.editPost": "Edit the post...",
@@ -3831,6 +3877,7 @@
"installed_incoming_webhooks.help.buildYourOwn": "Build Your Own",
"installed_incoming_webhooks.search": "Search Incoming Webhooks",
"installed_incoming_webhooks.unknown_channel": "A Private Webhook",
"installed_integrations.audience_urls": "Audience URLs: {urls}",
"installed_integrations.callback_urls": "Callback URLs: {urls}",
"installed_integrations.client_id": "Client ID: **{clientId}**",
"installed_integrations.client_secret": "Client Secret: **{clientSecret}**",
@@ -3844,9 +3891,11 @@
"installed_integrations.regenToken": "Regenerate Token",
"installed_integrations.showSecret": "Show Secret",
"installed_integrations.token": "Token: {token}",
"installed_integrations.token_url": "Token URL: {url}",
"installed_integrations.triggerWhen": "Trigger When: {triggerWhen}",
"installed_integrations.triggerWords": "Trigger Words: {triggerWords}",
"installed_integrations.unnamed_oauth_app": "Unnamed OAuth 2.0 Application",
"installed_integrations.unnamed_outgoing_oauth_connection": "Unnamed Outgoing OAuth Connection",
"installed_integrations.url": "URL: {url}",
"installed_oauth_apps.add": "Add OAuth 2.0 Application",
"installed_oauth_apps.callbackUrls": "Callback URLs (One Per Line)",
@@ -3870,6 +3919,18 @@
"installed_oauth_apps.trusted.no": "No",
"installed_oauth_apps.trusted.yes": "Yes",
"installed_oauth2_apps.header": "OAuth 2.0 Applications",
"installed_outgoing_oauth_connections.add": "Add Outgoing OAuth Connection",
"installed_outgoing_oauth_connections.client_secret": "Client Secret: ********",
"installed_outgoing_oauth_connections.delete.confirm": "Are you sure you want to delete {connectionName}?",
"installed_outgoing_oauth_connections.delete.wanring": "Deleting this connection will break any integrations using it",
"installed_outgoing_oauth_connections.empty": "No Outgoing OAuth Connections found",
"installed_outgoing_oauth_connections.emptySearch": "No Outgoing OAuth Connections match {searchTerm}",
"installed_outgoing_oauth_connections.header": "Outgoing OAuth Connections",
"installed_outgoing_oauth_connections.help": "Create {outgoingOauthConnections} to securely integrate bots and third-party apps with Mattermost.",
"installed_outgoing_oauth_connections.help.outgoingOauthConnections": "Outgoing OAuth Connections",
"installed_outgoing_oauth_connections.password": "Password: ********",
"installed_outgoing_oauth_connections.search": "Search Outgoing OAuth Connections",
"installed_outgoing_oauth_connections.username": "Username: **{username}**",
"installed_outgoing_webhooks.add": "Add Outgoing Webhook",
"installed_outgoing_webhooks.delete.confirm": "This action permanently deletes the outgoing webhook and breaks any integrations using it. Are you sure you want to delete it?",
"installed_outgoing_webhooks.empty": "No outgoing webhooks found",
@@ -3883,7 +3944,7 @@
"integrations.add": "Add",
"integrations.command.description": "Slash commands send events to external integrations",
"integrations.command.title": "Slash Commands",
"integrations.delete.confirm.button": "Delete",
"integrations.delete.confirm.button": "Yes, delete it",
"integrations.delete.confirm.title": "Delete Integration",
"integrations.done": "Done",
"integrations.edit": "Edit",
@@ -3894,6 +3955,8 @@
"integrations.incomingWebhook.title": "Incoming Webhooks",
"integrations.oauthApps.description": "OAuth 2.0 allows external applications to make authorized requests to the Mattermost API",
"integrations.oauthApps.title": "OAuth 2.0 Applications",
"integrations.outgoingOAuthConnections.description": "Outgoing OAuth Connections allow custom integrations to communicate to external systems",
"integrations.outgoingOAuthConnections.title": "Outgoing OAuth Connections",
"integrations.outgoingWebhook.description": "Outgoing webhooks allow external integrations to receive and respond to messages",
"integrations.outgoingWebhook.title": "Outgoing Webhooks",
"integrations.successful": "Setup Successful",
@@ -5307,6 +5370,8 @@
"update_incoming_webhook.updating": "Updating...",
"update_oauth_app.confirm": "Edit OAuth 2.0 application",
"update_oauth_app.question": "Your changes may break the existing OAuth 2.0 application. Are you sure you would like to update it?",
"update_outgoing_oauth_connection.confirm": "Edit Outgoing OAuth Connection",
"update_outgoing_oauth_connection.question": "Your changes may break any existing integrations using this connection. Are you sure you would like to update it?",
"update_outgoing_webhook.confirm": "Edit Outgoing Webhook",
"update_outgoing_webhook.question": "Your changes may break the existing outgoing webhook. Are you sure you would like to update it?",
"update_outgoing_webhook.update": "Update",

Двоичные данные
webapp/channels/src/images/outgoing_oauth_connection.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 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>;